Compare commits

...

89 Commits

Author SHA1 Message Date
Egor 966f7124c4 Update Dockerfile 2025-09-14 04:52:52 +03:00
Egor 1cebec5af4 Update docker-registry.yml 2025-09-14 04:52:41 +03:00
Egor d31b5097ff Update docker-hub.yml 2025-09-14 04:52:27 +03:00
Egor 21a8fe4025 Merge pull request #73 from Fr1ngg/dev
Медиа в рассылках
2025-09-14 04:43:51 +03:00
Egor 6a4adcfe0c Update admin.py 2025-09-14 04:39:28 +03:00
Egor c9acd46cd3 Update states.py 2025-09-14 04:38:55 +03:00
Egor 6018113a3d Update messages.py 2025-09-14 04:38:29 +03:00
Egor fe0825957a Update universal_migration.py 2025-09-14 04:37:48 +03:00
Egor d0cef51e35 Update models.py 2025-09-14 04:37:34 +03:00
Egor 2aa0d11f4c Merge pull request #72 from Fr1ngg/dev
Доработка раздела с правилами + валидация
2025-09-14 04:08:39 +03:00
Egor b8a1aa3f3c Add files via upload 2025-09-14 04:05:02 +03:00
Egor 80e95bc77b Update rules.py 2025-09-14 04:04:31 +03:00
Egor 87af0312cb Update validators.py 2025-09-14 04:04:05 +03:00
Egor e988c9aa14 Merge pull request #70 from Fr1ngg/dev
Новые уведомления об изменении доп услуг
2025-09-14 03:39:03 +03:00
Egor cdee98f690 Update subscription.py 2025-09-14 03:37:55 +03:00
Egor 268c619ad6 Update admin_notification_service.py 2025-09-14 03:37:24 +03:00
Egor 352434ae35 Update admin_notification_service.py 2025-09-14 03:06:23 +03:00
Egor b1e39bae32 Merge pull request #69 from yazhog/main
Кнопка подключиться в главное меню, Переход к покупке/продлению подписки с окна уведомления о платеже
2025-09-14 02:58:12 +03:00
yazhog 44a2c929c4 Merge pull request #19 from yazhog/codex/add-online-today-and-this-week
Display daily and weekly online stats in admin panel
2025-09-13 22:11:49 +03:00
yazhog 42a9865fac Display daily and weekly online stats in admin panel 2025-09-13 22:08:22 +03:00
yazhog c730863162 Merge pull request #18 from yazhog/codex/add-connect-button-to-main-menu
Add connect button to main menu
2025-09-13 21:50:46 +03:00
yazhog b58db19127 Подвигал кнопки 2025-09-13 21:49:43 +03:00
yazhog 2820c2d942 Add connect button to main menu 2025-09-13 21:33:04 +03:00
yazhog 0be172c675 Merge pull request #17 from yazhog/codex/add-subscription-button-to-payment-message-1bqk73
Add subscription button to payment success notification
2025-09-13 21:22:56 +03:00
yazhog b26fd77190 Add subscription buttons to all payment success messages 2025-09-13 21:20:24 +03:00
yazhog 9317a1703a Add subscription button to payment success notification 2025-09-13 21:04:53 +03:00
yazhog f166279507 Merge branch 'Fr1ngg:main' into main 2025-09-13 19:40:45 +03:00
Egor 60216519af Merge pull request #68 from Fr1ngg/dev
Добавляемые кнопки для рассылок
2025-09-13 14:23:32 +03:00
Egor ed83d31b22 Add files via upload 2025-09-13 14:19:53 +03:00
yazhog cf6e2955c7 Merge branch 'Fr1ngg:main' into main 2025-09-13 14:16:07 +03:00
Egor 4e3a183e2b Update Dockerfile 2025-09-13 11:07:33 +03:00
Egor 0879855d3f Update docker-registry.yml 2025-09-13 11:07:20 +03:00
Egor 2358552961 Update docker-hub.yml 2025-09-13 11:07:04 +03:00
Egor 35b461b60c Update README.md 2025-09-13 11:06:28 +03:00
Egor 1b5cfc3920 Update README.md 2025-09-13 10:23:10 +03:00
Egor 4a179ccc1e Update README.md 2025-09-13 10:22:18 +03:00
Egor 4921578af3 Update .env.example 2025-09-13 10:21:31 +03:00
Egor 55fb1f2044 Update CONTRIBUTING.md 2025-09-13 10:19:21 +03:00
Egor ad38aae7b0 Update issue templates 2025-09-13 10:16:27 +03:00
Egor 7e0840ef09 Create SECURITY.md 2025-09-13 10:15:33 +03:00
Egor 03fcb5f2f7 Create CONTRIBUTING.md 2025-09-13 10:12:53 +03:00
Egor 0c4ea86e03 Update users.py 2025-09-13 09:29:26 +03:00
Egor 209bb9763e Merge pull request #66 from Fr1ngg/dev
Исправлена ошибок с устаревшими callback queries при перезапуске бота
2025-09-13 09:27:31 +03:00
Egor 187de6807e Update decorators.py 2025-09-13 09:22:56 +03:00
Egor b8c9302018 Add files via upload 2025-09-13 09:21:32 +03:00
Egor a86fccffc0 Update bot.py 2025-09-13 09:19:55 +03:00
Egor a448adec93 Create global_error.py 2025-09-13 09:18:55 +03:00
Egor d45ff62a57 Update decorators.py 2025-09-13 09:07:22 +03:00
Egor 3c50fc6a71 Update README.md 2025-09-13 08:02:29 +03:00
Egor 292a120f74 Merge pull request #65 from Fr1ngg/dev
X-Api-key заголовки + TinyAuth
2025-09-13 07:55:55 +03:00
Egor a7868787a4 Update remnawave_service.py 2025-09-13 07:53:39 +03:00
Egor 5275f4fbeb Update maintenance_service.py 2025-09-13 07:52:28 +03:00
Egor ba25ec7ad0 Update subscription_service.py 2025-09-13 07:44:16 +03:00
Egor 301b4124ef Update .env.example 2025-09-13 07:36:17 +03:00
Egor a936a97e5e Update remnawave_service.py 2025-09-13 07:34:44 +03:00
Egor c390d18399 Update config.py 2025-09-13 07:34:16 +03:00
Egor 43df9bff48 Update remnawave_api.py 2025-09-13 07:33:29 +03:00
Egor c15a9ea7cd Update README.md 2025-09-13 06:57:19 +03:00
Egor 271742b306 Update main.py 2025-09-13 06:47:50 +03:00
Egor ebdadd81c0 Update Dockerfile 2025-09-13 05:50:25 +03:00
Egor 1364855303 Update docker-registry.yml 2025-09-13 05:50:14 +03:00
Egor b5d862b231 Update docker-hub.yml 2025-09-13 05:49:57 +03:00
Egor 048a8adaa1 Update README.md 2025-09-13 05:49:23 +03:00
Egor 4b7b999d43 Правка статистики серверов 2025-09-13 05:47:57 +03:00
Egor d02fa83af5 Merge pull request #64 from Fr1ngg/dev
Исправление синка счетчика юзеров на серверах в админ панели в разделе с подписками юзеров
2025-09-13 05:43:13 +03:00
Egor 89045095b1 Исправление синка счетчика юзеров на серверах в админ панели в разделе с подписками юзеров 2025-09-13 05:42:27 +03:00
Egor 0de721e67d Merge pull request #63 from Fr1ngg/dev
Изменение отображения юзеров в админке
2025-09-13 05:16:03 +03:00
Egor c7c171a1d3 Update users.py 2025-09-13 05:15:04 +03:00
Egor a2500148fd Merge pull request #62 from Fr1ngg/dev
Возможность скрыть ссылку на подписку в разделе с подпиской
2025-09-13 05:03:47 +03:00
Egor 99b8a3726b Update README.md 2025-09-13 05:02:44 +03:00
Egor 8b426e0f6c Update .env.example 2025-09-13 05:01:37 +03:00
Egor caca561997 Update subscription.py 2025-09-13 04:58:38 +03:00
Egor 385bd78021 Update .env.example 2025-09-13 04:57:33 +03:00
Egor 8ca127e93f Update config.py 2025-09-13 04:57:00 +03:00
Egor ac9b8aaf68 Merge pull request #61 from Fr1ngg/dev
Правки в админском меню
2025-09-13 04:49:29 +03:00
Egor 4939f56f1b Update states.py 2025-09-13 04:47:50 +03:00
Egor 659e950722 Update states.py 2025-09-13 04:44:30 +03:00
Egor 5186ae4334 Update main.py 2025-09-13 04:42:42 +03:00
Egor 64cd38e2c3 Update admin.py 2025-09-13 04:41:46 +03:00
Egor cc29c887c5 Merge pull request #60 from Legacyyy777/DEV
Первый раз в первый класс
2025-09-13 04:28:48 +03:00
Egor 7fd7dd7cc8 Delete .github/workflows/deploy.yaml 2025-09-13 04:28:09 +03:00
Legacyyy777 9ac3088b72 Добавлено отображение текущего количества онлайн-пользователей VPN в админ-панели. Обработаны возможные ошибки при получении статистики. 2025-09-13 04:17:29 +05:00
Legacyyy777 9745885f1b Create deploy.yaml 2025-09-13 03:37:04 +05:00
yazhog 818189e8e0 Merge branch 'Fr1ngg:main' into main 2025-09-13 00:53:39 +03:00
Egor 2ccaaebfc9 Update Dockerfile 2025-09-13 00:45:49 +03:00
yazhog 82e75f2170 Delete .github/workflows/deploy.yml 2025-09-13 00:25:08 +03:00
yazhog 1b265fd95b Update deploy.yml 2025-09-12 23:52:01 +03:00
yazhog 7a8905e0bc Update deploy.yml 2025-09-12 23:50:37 +03:00
yazhog 6d8ba6d30c Create deploy.yml 2025-09-12 23:38:19 +03:00
39 changed files with 3290 additions and 310 deletions
+15 -4
View File
@@ -34,9 +34,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=
# ========= ПОДПИСКИ =========
# ===== ТРИАЛ ПОДПИСКА =====
@@ -183,7 +191,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,6 +202,9 @@ CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
ENABLE_LOGO_MODE=true
LOGO_FILE=vpn_logo.png
# Скрыть блок с ссылкой подключения в разделе с информацией о подписке
HIDE_SUBSCRIPTION_LINK=false
# Режим работы кнопки "Подключиться"
# guide - открывает гайд подключения (режим 1)
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
+38
View File
@@ -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.
+10
View File
@@ -0,0 +1,10 @@
---
name: Custom issue template
about: Describe this issue template's purpose here.
title: ''
labels: ''
assignees: ''
---
+20
View File
@@ -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.
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.2.9-$(git rev-parse --short HEAD)"
VERSION="v2.3.2-$(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.9-dev-$(git rev-parse --short HEAD)"
VERSION="v2.3.2-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.9-pr-$(git rev-parse --short HEAD)"
VERSION="v2.3.2-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+3 -3
View File
@@ -52,13 +52,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.2.9"
VERSION="v2.3.2"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.2.9-dev-$(git rev-parse --short HEAD)"
VERSION="v2.3.2-dev-$(git rev-parse --short HEAD)"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.2.9-pr-$(git rev-parse --short HEAD)"
VERSION="v2.3.2-pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+480
View File
@@ -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
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.11-slim
ARG VERSION="v2.2.9"
ARG VERSION="v2.3.2"
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" \
+81 -10
View File
@@ -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>
@@ -585,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:
@@ -601,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:
@@ -624,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:
@@ -647,6 +709,7 @@ networks:
ipam:
config:
- subnet: 172.20.0.0/16
gateway: 172.20.0.1
```
### 🚀 Команды управления
@@ -829,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 # Кеширование
@@ -1089,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
View File
@@ -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 при необходимости
- Сотрудничество с исследователями безопасности
---
**Помните**: Безопасность - это постоянный процесс, а не одноразовая настройка. Регулярно обновляйте зависимости, следите за новостями безопасности и не стесняйтесь сообщать о подозрительной активности.
+5 -4
View File
@@ -5,6 +5,7 @@ from aiogram.fsm.storage.memory import MemoryStorage
import redis.asyncio as redis
from app.config import settings
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
@@ -75,6 +76,9 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
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 +90,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.callback_query.middleware(ThrottlingMiddleware())
dp.message.middleware(SubscriptionStatusMiddleware())
dp.callback_query.middleware(SubscriptionStatusMiddleware())
start.register_handlers(dp)
menu.register_handlers(dp)
subscription.register_handlers(dp)
@@ -94,7 +97,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 +113,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 +123,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
+15
View File
@@ -32,6 +32,10 @@ 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"
TRIAL_DURATION_DAYS: int = 3
TRIAL_TRAFFIC_LIMIT_GB: int = 10
@@ -138,6 +142,7 @@ 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
@@ -237,6 +242,16 @@ 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 get_autopay_warning_days(self) -> List[int]:
try:
+139 -3
View File
@@ -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)
}
+39 -20
View File
@@ -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
+4 -1
View File
@@ -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):
+64 -1
View File
@@ -361,6 +361,49 @@ async def create_welcome_texts_table():
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:
@@ -639,6 +682,13 @@ async def run_universal_migration():
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:
@@ -689,6 +739,7 @@ async def run_universal_migration():
logger.info("✅ CryptoBot таблица готова")
logger.info("✅ Таблица конверсий подписок создана")
logger.info("✅ Таблица welcome_texts с полем is_enabled готова")
logger.info("✅ Медиа поля в broadcast_history добавлены")
logger.info("✅ Дубликаты подписок исправлены")
return True
@@ -706,6 +757,7 @@ async def check_migration_status():
"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
}
@@ -719,6 +771,14 @@ async def check_migration_status():
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 (
@@ -737,6 +797,7 @@ async def check_migration_status():
"user_messages_table": "Таблица пользовательских сообщений",
"welcome_texts_table": "Таблица приветственных текстов",
"welcome_texts_is_enabled_column": "Поле is_enabled в welcome_texts",
"broadcast_history_media_fields": "Медиа поля в broadcast_history",
"subscription_conversions_table": "Таблица конверсий подписок",
"subscription_duplicates": "Отсутствие дубликатов подписок"
}
@@ -755,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}")
+30 -10
View File
@@ -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
View File
@@ -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")
)
+452 -20
View File
@@ -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(
@@ -260,15 +326,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
):
media_type = callback.data.replace('add_media_', '')
if media_type == "skip":
await state.update_data(has_media=False)
await show_button_selector_callback(callback, db_user, state)
return
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 +616,9 @@ async def process_broadcast_message(
👥 <b>Получателей:</b> {user_count}
📝 <b>Сообщение:</b>
{broadcast_text}
{message_text}{media_info}
{buttons_info}
Подтвердить отправку?
"""
@@ -284,16 +626,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 +692,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 +713,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 +731,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 +782,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 +793,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 +807,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 +840,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 +888,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 +982,11 @@ 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_") | F.data.in_(["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)
+223 -42
View File
@@ -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)
+71 -27
View File
@@ -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}"
)
])
+5 -3
View File
@@ -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"
)
+34 -3
View File
@@ -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)}"
+10 -5
View File
@@ -147,7 +147,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"
)
@@ -448,7 +449,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"
)
@@ -563,7 +565,8 @@ async def complete_registration_from_callback(
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"
)
@@ -611,7 +614,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"
)
@@ -726,7 +730,8 @@ async def complete_registration(
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"
)
+34 -7
View File
@@ -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)}"
+172 -36
View File
@@ -9,33 +9,15 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text=texts.ADMIN_USERS, callback_data="admin_users"),
InlineKeyboardButton(text=texts.ADMIN_SUBSCRIPTIONS, callback_data="admin_subscriptions")
InlineKeyboardButton(text="👥 Юзеры и Подписки", callback_data="admin_submenu_users"),
InlineKeyboardButton(text="💰 Промокоды и статистика", callback_data="admin_submenu_promo")
],
[
InlineKeyboardButton(text=texts.ADMIN_PROMOCODES, callback_data="admin_promocodes"),
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
InlineKeyboardButton(text="📨 Коммуникации", callback_data="admin_submenu_communications"),
InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_submenu_settings")
],
[
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_STATISTICS, callback_data="admin_statistics"),
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_updates"),
InlineKeyboardButton(text="🗄️ Бекапы", callback_data="backup_panel")
InlineKeyboardButton(text="🛠️ Системные функции", callback_data="admin_submenu_system")
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
@@ -43,6 +25,86 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
])
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_REFERRALS, callback_data="admin_referrals")
],
[
InlineKeyboardButton(text=texts.ADMIN_SUBSCRIPTIONS, callback_data="admin_subscriptions")
],
[
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="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="backup_panel")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
]
])
def get_admin_users_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
@@ -54,7 +116,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 +135,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 +150,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 +182,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 +201,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 +214,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 +239,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 +382,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"),
@@ -651,8 +713,8 @@ def get_maintenance_keyboard(
callback_data="maintenance_panel"
),
InlineKeyboardButton(
text=" Назад",
callback_data="admin_panel"
text=" Назад",
callback_data="admin_submenu_settings"
)
])
@@ -689,9 +751,83 @@ def get_welcome_text_keyboard(language: str = "ru", is_enabled: bool = True) ->
InlineKeyboardButton(text="💡 Плейсхолдеры", callback_data="show_placeholders_help")
],
[
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)
+39 -7
View File
@@ -39,7 +39,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 +52,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
View File
+148
View File
@@ -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
+94 -1
View File
@@ -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)
+8 -1
View File
@@ -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)
+144 -14
View File
@@ -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:
+14 -11
View File
@@ -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
@@ -873,7 +876,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 +893,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 +902,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 +911,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 +920,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 +941,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 +954,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:
+6 -2
View File
@@ -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(
+36 -7
View File
@@ -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
View File
@@ -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()
+78 -25
View File
@@ -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
View File
@@ -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>&lt;b&gt;жирный&lt;/b&gt;</code> или <code>&lt;strong&gt;жирный&lt;/strong&gt;</code>
- <code>&lt;i&gt;курсив&lt;/i&gt;</code> или <code>&lt;em&gt;курсив&lt;/em&gt;</code>
- <code>&lt;u&gt;подчеркнутый&lt;/u&gt;</code>
- <code>&lt;s&gt;зачеркнутый&lt;/s&gt;</code>
- <code>&lt;code&gt;моноширинный&lt;/code&gt;</code>
- <code>&lt;pre&gt;блок кода&lt;/pre&gt;</code>
- <code>&lt;a href="url"&gt;ссылка&lt;/a&gt;</code>
- <code>&lt;blockquote&gt;цитата&lt;/blockquote&gt;</code>
<code>&lt;b&gt;жирный&lt;/b&gt;</code> или <code>&lt;strong&gt;жирный&lt;/strong&gt;</code>
<code>&lt;i&gt;курсив&lt;/i&gt;</code> или <code>&lt;em&gt;курсив&lt;/em&gt;</code>
<code>&lt;u&gt;подчеркнутый&lt;/u&gt;</code>
<code>&lt;s&gt;зачеркнутый&lt;/s&gt;</code>
<code>&lt;code&gt;моноширинный&lt;/code&gt;</code>
<code>&lt;pre&gt;блок кода&lt;/pre&gt;</code>
<code>&lt;a href="url"&gt;ссылка&lt;/a&gt;</code>
<code>&lt;blockquote&gt;цитата&lt;/blockquote&gt;</code>
<b>Неподдерживаемые теги:</b> &lt;br&gt;, &lt;p&gt;, &lt;div&gt;, &lt;span&gt;, &lt;spoiler&gt; и другие"""
<b> Важные правила:</b>
Каждый открывающий тег должен быть закрыт
Теги должны быть правильно вложены
Атрибуты ссылок берите в кавычки
<b> Неправильно:</b>
<code>&lt;b&gt;жирный &lt;i&gt;курсив&lt;/b&gt;&lt;/i&gt;</code>
<code>&lt;a href=google.com&gt;ссылка&lt;/a&gt;</code>
<b> Правильно:</b>
<code>&lt;b&gt;жирный &lt;i&gt;курсив&lt;/i&gt;&lt;/b&gt;</code>
<code>&lt;a href="https://google.com"&gt;ссылка&lt;/a&gt;</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
+20 -10
View File
@@ -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():