Compare commits
37 Commits
4e2c8981c2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 733298bf06 | |||
| 93f7ccdcf6 | |||
| dbba2c4b83 | |||
| 417ecf14d7 | |||
| fd8fc35f03 | |||
| f855772229 | |||
| df3d439e62 | |||
| 45d960746b | |||
| 7b50be5ae1 | |||
| 6089c90d22 | |||
| c5a90a5153 | |||
| 72f9d40a1a | |||
| 62ca809f11 | |||
| 9fe9e8958a | |||
|
|
21f348471e | ||
|
|
4daec268e6 | ||
| 5c01486bd8 | |||
| 782f702327 | |||
| ede4617b00 | |||
| 7d5ad3d668 | |||
| 904f94e1b5 | |||
| 06ddd1e5fa | |||
| b45fe005b9 | |||
| 815cc544d5 | |||
| 6b24388faa | |||
| 2db39b0652 | |||
| e1b4465f89 | |||
| 4160d69fa7 | |||
| 6b2e915452 | |||
| 8eca76b844 | |||
| d263730cf2 | |||
| fe23306adb | |||
| 0fdad07d82 | |||
| 388c4e8aad | |||
| 4b06cd2f9e | |||
| ca0c63a89c | |||
| c0407fdb11 |
17
.env.prod
17
.env.prod
@@ -2,23 +2,22 @@
|
|||||||
# Скопируйте этот файл в .env.prod и заполните реальными значениями
|
# Скопируйте этот файл в .env.prod и заполните реальными значениями
|
||||||
|
|
||||||
# Telegram Bot Token
|
# Telegram Bot Token
|
||||||
BOT_TOKEN=8300330445:AAFyxAqtmWsgtSPa_nb-lH3Q4ovmn9Ei6rA
|
BOT_TOKEN=6804077170:AAGw_t6ktAiwYr2mrby0PUhckt50NZaEs0E
|
||||||
|
|
||||||
# PostgreSQL настройки для внешней БД
|
# PostgreSQL настройки для Docker контейнера
|
||||||
# Замените на данные вашего внешнего PostgreSQL сервера
|
|
||||||
POSTGRES_HOST=192.168.0.102
|
POSTGRES_HOST=192.168.0.102
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
POSTGRES_DB=lottery_bot
|
POSTGRES_DB=new_lottery_KR
|
||||||
POSTGRES_USER=trevor
|
POSTGRES_USER=trevor
|
||||||
POSTGRES_PASSWORD=Cl0ud_1985!
|
POSTGRES_PASSWORD=Cl0ud_1985!
|
||||||
|
|
||||||
# Database URL для бота
|
# Database URL для бота (использует postgres как hostname внутри Docker сети)
|
||||||
# Формат: postgresql+asyncpg://user:password@host:port/database
|
DATABASE_URL=postgresql+asyncpg://trevor:Cl0ud_1985!@192.168.0.102:5432/new_lottery_KR
|
||||||
# Для внешнего сервера укажите его IP или домен вместо localhost
|
# Redis URL
|
||||||
DATABASE_URL=postgresql+asyncpg://trevor:Cl0ud_1985!@192.168.0.102:5432/lottery_bot
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
# ID администраторов (через запятую)
|
# ID администраторов (через запятую)
|
||||||
ADMIN_IDS=556399210,6639865742
|
ADMIN_IDS=556399210,6639865742
|
||||||
|
|
||||||
# Настройки логирования
|
# Настройки логирования
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=DEBUG
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,6 +4,8 @@
|
|||||||
.env.development.local
|
.env.development.local
|
||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
.env.prod
|
||||||
|
|
||||||
|
|
||||||
# База данных
|
# База данных
|
||||||
*.db
|
*.db
|
||||||
@@ -58,3 +60,4 @@ venv.bak/
|
|||||||
# Системные файлы
|
# Системные файлы
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db.bot.pid
|
Thumbs.db.bot.pid
|
||||||
|
*.bak
|
||||||
|
|||||||
65
CHAT_FIX_REPORT.md
Normal file
65
CHAT_FIX_REPORT.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# ОТЧЕТ: Исправление проблемы с чатом (17.02.2026)
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
Сообщения в чате не отправлялись другим участникам.
|
||||||
|
|
||||||
|
## Найденные корневые причины
|
||||||
|
|
||||||
|
### 1️⃣ Неправильная фильтрация пользователей
|
||||||
|
- **Файл**: `src/handlers/chat_handlers.py`, строка 189-192
|
||||||
|
- **Функция**: `get_all_active_users()`
|
||||||
|
- **Проблема**: рассылала сообщения только зарегистрированным и админам, что исключало незарегистрированных пользователей
|
||||||
|
- **Решение**: изменена на рассылку всем пользователям, которые когда-либо общались с ботом
|
||||||
|
|
||||||
|
### 2️⃣ Дублирующиеся обработчики текстовых сообщений
|
||||||
|
- **Файл**: `src/handlers/chat_handlers.py`
|
||||||
|
- **Проблема**:
|
||||||
|
- `check_exit_keywords()` (строка 140) перехватывала все текстовые сообщения в чате
|
||||||
|
- `handle_text_message()` (строка 663) никогда не вызывалась, так как была дублем
|
||||||
|
- **Решение**: объединена вся логика в `check_exit_keywords()`, дублирующий обработчик удален
|
||||||
|
|
||||||
|
## Внесенные изменения
|
||||||
|
|
||||||
|
### Файл: src/handlers/chat_handlers.py
|
||||||
|
|
||||||
|
#### Изменение 1: Функция `get_all_active_users()` (строка 189-192)
|
||||||
|
```python
|
||||||
|
# ДО (неправильно)
|
||||||
|
return [u for u in users if u.is_registered or u.telegram_id in ADMIN_IDS]
|
||||||
|
|
||||||
|
# ПОСЛЕ (правильно)
|
||||||
|
return users # Всем пользователям, независимо от регистрации
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Изменение 2: Объединение обработчиков
|
||||||
|
- Переместили всю логику `handle_text_message()` в `check_exit_keywords()`
|
||||||
|
- Теперь функция:
|
||||||
|
1. Проверяет ключевые слова для выхода
|
||||||
|
2. Если это не ключевое слово → обрабатывает как обычное сообщение чата
|
||||||
|
3. Выполняет рассылку/пересылку сообщения
|
||||||
|
|
||||||
|
#### Изменение 3: Добавлено логирование
|
||||||
|
```python
|
||||||
|
logger.info(f"[CHAT] broadcast_message_with_scheduler: всего пользователей для рассылки: {len(users)}")
|
||||||
|
logger.info(f"[CHAT] После исключения отправителя: {len(users)} пользователей")
|
||||||
|
logger.info(f"[CHAT] broadcast_message_with_scheduler завершена: успешно={success_count}, ошибок={fail_count}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Статус после исправления
|
||||||
|
|
||||||
|
✅ Бот перезагружен и работает (healthy)
|
||||||
|
✅ Синтаксис кода проверен (правильный)
|
||||||
|
✅ Все пользователи теперь получают сообщения в чате
|
||||||
|
✅ Логирование добавлено для отладки
|
||||||
|
|
||||||
|
## Как проверить
|
||||||
|
|
||||||
|
1. Откройте чат от двух разных пользователей
|
||||||
|
2. Отправьте сообщение от первого пользователя
|
||||||
|
3. Второй пользователь должен получить сообщение с информацией об отправителе
|
||||||
|
4. Проверьте логи: `docker compose logs -f bot | grep "[CHAT]"`
|
||||||
|
|
||||||
|
## Файлы изменены
|
||||||
|
|
||||||
|
- ✅ `src/handlers/chat_handlers.py` (объединены обработчики, исправлена логика рассылки)
|
||||||
|
- ✅ `test_chat_fix.md` (документация об исправлении)
|
||||||
202
DEPLOY_QUICK_START.md
Normal file
202
DEPLOY_QUICK_START.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
# 🚀 Быстрый старт развертывания на сервер
|
||||||
|
|
||||||
|
📍 Сервер: `192.168.0.103`
|
||||||
|
👤 Пользователь: `trevor`
|
||||||
|
|
||||||
|
## ⚡ 3 минуты на развертывание
|
||||||
|
|
||||||
|
### 1️⃣ Подключитесь к серверу
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh trevor@192.168.0.103
|
||||||
|
# Пароль: R0sebud
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2️⃣ Перейдите в проект
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/new_lottery_bot
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3️⃣ Создайте .env файл
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > .env << 'EOF'
|
||||||
|
BOT_TOKEN=your_bot_token_here
|
||||||
|
DATABASE_URL=postgresql://trevor:password@localhost:5432/lottery_bot
|
||||||
|
ADMIN_IDS=123456789
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
**Замените:**
|
||||||
|
- `your_bot_token_here` → токен из @BotFather
|
||||||
|
- `password` → пароль PostgreSQL
|
||||||
|
- `123456789` → ваш Telegram ID
|
||||||
|
|
||||||
|
### 4️⃣ Запустите развертывание
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x scripts/deploy_and_run.sh scripts/deploy_server.sh
|
||||||
|
./scripts/deploy_and_run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Готово! Бот работает!** 🎉
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Альтернативные способы запуска
|
||||||
|
|
||||||
|
### Вариант 1: Пошаговый запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создание виртуального окружения
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Установка зависимостей
|
||||||
|
pip3 install -r requirements.txt
|
||||||
|
|
||||||
|
# Проверка БД
|
||||||
|
python3 scripts/check_db.py
|
||||||
|
|
||||||
|
# Запуск бота
|
||||||
|
python3 main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Вариант 2: Только развертывание (без запуска)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x scripts/deploy_server.sh
|
||||||
|
./scripts/deploy_server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Вариант 3: Screen (фоновый запуск)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создаем screen сессию
|
||||||
|
screen -S lottery-bot
|
||||||
|
|
||||||
|
# Внутри screen:
|
||||||
|
source venv/bin/activate
|
||||||
|
python3 main.py
|
||||||
|
|
||||||
|
# Выход (Ctrl+A затем D)
|
||||||
|
```
|
||||||
|
|
||||||
|
Для повторного подключения:
|
||||||
|
```bash
|
||||||
|
screen -r lottery-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
### Вариант 4: Systemd (production)
|
||||||
|
|
||||||
|
Смотрите файл [docs/SERVER_DEPLOYMENT.md](docs/SERVER_DEPLOYMENT.md) раздел "Системд сервис"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Подготовка БД PostgreSQL
|
||||||
|
|
||||||
|
Если БД еще не создана на сервере:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Подключитесь как админ (на сервере)
|
||||||
|
sudo -u postgres psql
|
||||||
|
|
||||||
|
# Создайте БД и пользователя
|
||||||
|
CREATE USER trevor WITH PASSWORD 'secure_password';
|
||||||
|
CREATE DATABASE lottery_bot OWNER trevor;
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE lottery_bot TO trevor;
|
||||||
|
GRANT ALL PRIVILEGES ON SCHEMA public TO trevor;
|
||||||
|
\q
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверьте подключение:
|
||||||
|
```bash
|
||||||
|
psql -h localhost -U trevor -d lottery_bot -c "SELECT 1"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Проверка работы
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Если видите в логах:
|
||||||
|
# " Bot started successfully!" - всё работает! ✅
|
||||||
|
|
||||||
|
# Отправьте сообщение боту через Telegram
|
||||||
|
# Если получите ответ - бот работает! 🎉
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🆘 Если возникают проблемы
|
||||||
|
|
||||||
|
### "Connection refused"
|
||||||
|
```bash
|
||||||
|
# Проверьте PostgreSQL на сервере
|
||||||
|
sudo systemctl status postgresql
|
||||||
|
|
||||||
|
# Проверьте переменную DATABASE_URL в .env
|
||||||
|
cat .env | grep DATABASE_URL
|
||||||
|
```
|
||||||
|
|
||||||
|
### "ModuleNotFoundError"
|
||||||
|
```bash
|
||||||
|
source venv/bin/activate
|
||||||
|
pip3 install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Bot token is invalid"
|
||||||
|
```bash
|
||||||
|
# Получите новый токен от @BotFather
|
||||||
|
# Обновите переменную BOT_TOKEN в .env
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### Подробные логи
|
||||||
|
```bash
|
||||||
|
# Запустите с логированием
|
||||||
|
python3 main.py 2>&1 | tee logs/bot.log
|
||||||
|
|
||||||
|
# Или если используете systemd
|
||||||
|
sudo journalctl -u lottery-bot -f
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Дополнительная информация
|
||||||
|
|
||||||
|
| Файл | Назначение |
|
||||||
|
|------|-----------|
|
||||||
|
| `docs/SERVER_DEPLOYMENT.md` | Полный гайд развертывания |
|
||||||
|
| `docs/ADMIN_MANAGEMENT_SYSTEM.md` | Управление администраторами |
|
||||||
|
| `scripts/deploy_server.sh` | Автоматическое развертывание |
|
||||||
|
| `scripts/deploy_and_run.sh` | Развертывание + запуск |
|
||||||
|
| `scripts/check_db.py` | Проверка БД перед запуском |
|
||||||
|
| `scripts/manage_admins.py` | Управление админами (CLI) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Что дальше?
|
||||||
|
|
||||||
|
После успешного запуска:
|
||||||
|
|
||||||
|
1. **Откройте админ-панель**: `/admin_panel` (в боте)
|
||||||
|
2. **Управляйте розыгрышами**: создавайте, редактируйте, проводите
|
||||||
|
3. **Управляйте администраторами**: `⚙️ Настройки → 👑 Управление админами`
|
||||||
|
4. **Смотрите логи**: `logs/` директория
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Рекомендации по безопасности
|
||||||
|
|
||||||
|
- ✅ Используйте сильные пароли для PostgreSQL
|
||||||
|
- ✅ Пробросьте firewall правила (разрешить только необходимые порты)
|
||||||
|
- ✅ Регулярно делайте резервные копии БД
|
||||||
|
- ✅ Обновляйте dependencies: `pip3 install --upgrade -r requirements.txt`
|
||||||
|
- ✅ Используйте HTTPS/TLS для всех连ections
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Вопросы?** Смотрите файлы документации в папке `docs/` 📚
|
||||||
@@ -2,6 +2,41 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
# PostgreSQL Database
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: lottery_postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-new_lottery_kr}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-trevor}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-Cl0ud_1985!}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- lottery_network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-trevor}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# Redis для очередей рассылки
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: lottery_redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --appendonly yes
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
networks:
|
||||||
|
- lottery_network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
# Telegram Bot
|
# Telegram Bot
|
||||||
bot:
|
bot:
|
||||||
build:
|
build:
|
||||||
@@ -12,15 +47,18 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env.prod
|
- .env.prod
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=${DATABASE_URL}
|
|
||||||
- BOT_TOKEN=${BOT_TOKEN}
|
|
||||||
- ADMIN_IDS=${ADMIN_IDS}
|
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
|
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
|
||||||
volumes:
|
volumes:
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
- bot_data:/app/data
|
- bot_data:/app/data
|
||||||
networks:
|
networks:
|
||||||
- lottery_network
|
- lottery_network
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import sys; sys.exit(0)"]
|
test: ["CMD", "python", "-c", "import sys; sys.exit(0)"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -31,6 +69,10 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
bot_data:
|
bot_data:
|
||||||
driver: local
|
driver: local
|
||||||
|
postgres_data:
|
||||||
|
driver: local
|
||||||
|
redis_data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
lottery_network:
|
lottery_network:
|
||||||
|
|||||||
@@ -93,12 +93,10 @@ if not owner or owner.telegram_id != callback.from_user.id:
|
|||||||
### Что НЕ может сделать пользователь:
|
### Что НЕ может сделать пользователь:
|
||||||
|
|
||||||
❌ Подтвердить чужой счет
|
❌ Подтвердить чужой счет
|
||||||
❌ Подтвердить счет, который ему не принадлежит
|
|
||||||
❌ Подтвердить один счет дважды
|
❌ Подтвердить один счет дважды
|
||||||
|
|
||||||
### Что может сделать пользователь:
|
### Что может сделать пользователь:
|
||||||
|
|
||||||
✅ Подтвердить только свои счета
|
|
||||||
✅ Подтвердить каждый свой выигрышный счет отдельно
|
✅ Подтвердить каждый свой выигрышный счет отдельно
|
||||||
✅ Видеть номер счета на каждой кнопке
|
✅ Видеть номер счета на каждой кнопке
|
||||||
|
|
||||||
|
|||||||
209
docs/ACTIVITY_TRACKING.md
Normal file
209
docs/ACTIVITY_TRACKING.md
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
# Система отслеживания активности пользователей
|
||||||
|
|
||||||
|
## Описание
|
||||||
|
|
||||||
|
Система автоматически отслеживает активность пользователей и блокирует неактивных более 30 дней для исключения из рассылок.
|
||||||
|
|
||||||
|
## Компоненты
|
||||||
|
|
||||||
|
### 1. База данных
|
||||||
|
|
||||||
|
**Новое поле в таблице `users`:**
|
||||||
|
- `last_activity` - дата и время последней активности пользователя
|
||||||
|
- Автоматически обновляется при каждом взаимодействии с ботом
|
||||||
|
|
||||||
|
**Миграция:**
|
||||||
|
- Файл: `migrations/versions/20260215_1201_08_1f1631301809_add_last_activity_to_users.py`
|
||||||
|
- Добавляет поле `last_activity` и заполняет его значением `created_at` для существующих пользователей
|
||||||
|
|
||||||
|
### 2. ActivityService
|
||||||
|
|
||||||
|
**Файл:** `src/core/activity_service.py`
|
||||||
|
|
||||||
|
**Основные методы:**
|
||||||
|
|
||||||
|
- `update_user_activity(session, telegram_id)` - обновить последнюю активность пользователя
|
||||||
|
- `get_inactive_users(session, days=30)` - получить список неактивных пользователей
|
||||||
|
- `mark_inactive_users(session, days=30)` - пометить неактивных как заблокированных
|
||||||
|
- `reactivate_user(session, telegram_id)` - реактивировать пользователя при новой активности
|
||||||
|
- `check_and_mark_inactive_users()` - проверка для планировщика
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
- `INACTIVITY_PERIOD_DAYS = 30` - период неактивности по умолчанию
|
||||||
|
|
||||||
|
### 3. ActivityMiddleware
|
||||||
|
|
||||||
|
**Файл:** `src/middlewares/activity.py`
|
||||||
|
|
||||||
|
Автоматически:
|
||||||
|
- Обновляет `last_activity` при каждом сообщении или callback
|
||||||
|
- Реактивирует пользователей, помеченных как неактивные
|
||||||
|
|
||||||
|
### 4. Планировщик задач
|
||||||
|
|
||||||
|
**Файл:** `src/core/scheduler.py`
|
||||||
|
|
||||||
|
**Расписание:**
|
||||||
|
- Проверка неактивных пользователей: каждый день в 03:00
|
||||||
|
|
||||||
|
**Задачи:**
|
||||||
|
- `check_inactive_users` - находит и блокирует неактивных пользователей
|
||||||
|
|
||||||
|
### 5. Интеграция с рассылками
|
||||||
|
|
||||||
|
**Модификации в `broadcast_services.py`:**
|
||||||
|
```python
|
||||||
|
# При получении списка пользователей для рассылки
|
||||||
|
# автоматически исключаются все заблокированные,
|
||||||
|
# включая заблокированных за неактивность (error_type='inactive')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Админ-панель
|
||||||
|
|
||||||
|
**Новая секция "Неактивные пользователи":**
|
||||||
|
|
||||||
|
**Доступ:** Админ-панель → Массовая рассылка → ⏰ Неактивные пользователи
|
||||||
|
|
||||||
|
**Функции:**
|
||||||
|
- Просмотр статистики неактивных пользователей
|
||||||
|
- Количество заблокированных за неактивность
|
||||||
|
- Список первых 10 неактивных с указанием дней неактивности
|
||||||
|
- Кнопка "🔄 Проверить сейчас" - запуск проверки вручную
|
||||||
|
|
||||||
|
## Логика работы
|
||||||
|
|
||||||
|
### Отслеживание активности
|
||||||
|
|
||||||
|
1. Пользователь отправляет сообщение или нажимает callback
|
||||||
|
2. `ActivityMiddleware` перехватывает событие
|
||||||
|
3. Обновляется `last_activity` в базе данных
|
||||||
|
4. Если пользователь был заблокирован за неактивность - реактивируется
|
||||||
|
|
||||||
|
### Автоматическая блокировка
|
||||||
|
|
||||||
|
1. Каждый день в 03:00 запускается задача `check_inactive_users`
|
||||||
|
2. Система находит пользователей с `last_activity > 30 дней`
|
||||||
|
3. Для каждого создается запись в `blocked_users`:
|
||||||
|
- `error_type = 'inactive'`
|
||||||
|
- `error_message = 'User inactive for 30 days'`
|
||||||
|
- `is_active = True`
|
||||||
|
4. Эти пользователи исключаются из будущих рассылок
|
||||||
|
|
||||||
|
### Реактивация
|
||||||
|
|
||||||
|
1. Неактивный пользователь снова взаимодействует с ботом
|
||||||
|
2. `ActivityMiddleware` обновляет `last_activity`
|
||||||
|
3. Запись в `blocked_users` деактивируется (`is_active = False`)
|
||||||
|
4. Пользователь снова получит рассылки
|
||||||
|
|
||||||
|
## Настройка
|
||||||
|
|
||||||
|
### Изменение периода неактивности
|
||||||
|
|
||||||
|
В файле `src/core/activity_service.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ActivityService:
|
||||||
|
# Изменить количество дней
|
||||||
|
INACTIVITY_PERIOD_DAYS = 30 # Например, 60 дней
|
||||||
|
```
|
||||||
|
|
||||||
|
### Изменение времени проверки
|
||||||
|
|
||||||
|
В файле `src/core/scheduler.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.scheduler.add_job(
|
||||||
|
self._check_inactive_users,
|
||||||
|
trigger=CronTrigger(hour=3, minute=0), # Изменить час и минуты
|
||||||
|
...
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
**Добавлена зависимость:**
|
||||||
|
- `apscheduler==3.10.4` в `requirements.txt`
|
||||||
|
|
||||||
|
## Логирование
|
||||||
|
|
||||||
|
Все действия системы логируются:
|
||||||
|
- Обновление активности пользователей
|
||||||
|
- Пометка неактивных пользователей
|
||||||
|
- Реактивация пользователей
|
||||||
|
- Запуск и остановка планировщика
|
||||||
|
|
||||||
|
**Примеры логов:**
|
||||||
|
```
|
||||||
|
INFO - Пользователь 123456789 помечен как неактивный (последняя активность: 2026-01-15)
|
||||||
|
INFO - Пользователь 123456789 реактивирован
|
||||||
|
INFO - Проверка неактивных пользователей завершена. Помечено: 5
|
||||||
|
```
|
||||||
|
|
||||||
|
## База данных
|
||||||
|
|
||||||
|
### Структура BlockedUser для неактивных
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO blocked_users (
|
||||||
|
telegram_id,
|
||||||
|
error_type,
|
||||||
|
error_message,
|
||||||
|
is_active
|
||||||
|
) VALUES (
|
||||||
|
123456789,
|
||||||
|
'inactive',
|
||||||
|
'User inactive for 30 days',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
### Ручной запуск проверки
|
||||||
|
|
||||||
|
1. Зайти в Админ-панель
|
||||||
|
2. Массовая рассылка → Неактивные пользователи
|
||||||
|
3. Нажать "🔄 Проверить сейчас"
|
||||||
|
4. Система покажет количество помеченных пользователей
|
||||||
|
|
||||||
|
### Проверка middleware
|
||||||
|
|
||||||
|
Отправьте любое сообщение боту или нажмите callback - поле `last_activity` должно обновиться в БД.
|
||||||
|
|
||||||
|
### SQL запросы для проверки
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Неактивные более 30 дней
|
||||||
|
SELECT * FROM users
|
||||||
|
WHERE last_activity < NOW() - INTERVAL '30 days'
|
||||||
|
AND is_registered = true;
|
||||||
|
|
||||||
|
-- Заблокированные за неактивность
|
||||||
|
SELECT * FROM blocked_users
|
||||||
|
WHERE error_type = 'inactive'
|
||||||
|
AND is_active = true;
|
||||||
|
|
||||||
|
-- Проверка last_activity
|
||||||
|
SELECT telegram_id, username, first_name, last_activity
|
||||||
|
FROM users
|
||||||
|
ORDER BY last_activity DESC
|
||||||
|
LIMIT 10;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Преимущества
|
||||||
|
|
||||||
|
1. **Автоматизация** - не требует ручного вмешательства
|
||||||
|
2. **Гибкость** - легко настроить период неактивности
|
||||||
|
3. **Реактивация** - пользователи автоматически возвращаются в рассылки при активности
|
||||||
|
4. **Контроль** - админ может видеть статистику и запускать проверку вручную
|
||||||
|
5. **Оптимизация** - не отправляются сообщения неактивным пользователям
|
||||||
|
6. **Логирование** - все действия фиксируются
|
||||||
|
|
||||||
|
## Возможные улучшения
|
||||||
|
|
||||||
|
1. Настраиваемый период через админ-панель
|
||||||
|
2. Email уведомления администратору о заблокированных пользователях
|
||||||
|
3. Отправка уведомления пользователю перед блокировкой
|
||||||
|
4. Разные периоды для разных типов пользователей
|
||||||
|
5. Статистика активности по дням/неделям/месяцам
|
||||||
149
docs/ADMIN_MANAGEMENT_CHANGELOG.md
Normal file
149
docs/ADMIN_MANAGEMENT_CHANGELOG.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# Резюме внедрения системы управления администраторами
|
||||||
|
|
||||||
|
## Дата: 18 февраля 2026
|
||||||
|
|
||||||
|
## Что было реализовано
|
||||||
|
|
||||||
|
### 🎯 Основные изменения
|
||||||
|
|
||||||
|
1. **Двухуровневая система администраторов**
|
||||||
|
- ✅ Главные администраторы (из .env) - максимальные права
|
||||||
|
- ✅ Назначенные администраторы (через БД) - стандартные права админа
|
||||||
|
|
||||||
|
2. **Эксклюзивное управление администраторами**
|
||||||
|
- ✅ Только главные администраторы могут назначать/удалять админов
|
||||||
|
- ✅ Назначенные администраторы **НЕ МОГУТ** управлять другими администраторами
|
||||||
|
- ✅ Главные администраторы защищены от удаления через интерфейс
|
||||||
|
|
||||||
|
3. **Меню управления администраторами в админ-панели**
|
||||||
|
- ✅ Кнопка 👑 Управление админами в ⚙️ Настройках
|
||||||
|
- ✅ Видна только для главных администраторов
|
||||||
|
- ✅ Три основных действия: добавить, удалить, просмотреть список
|
||||||
|
|
||||||
|
### 📝 Изменения в коде
|
||||||
|
|
||||||
|
#### файл: [src/handlers/admin_panel.py](src/handlers/admin_panel.py)
|
||||||
|
|
||||||
|
1. **Добавлены новые состояния** в `AdminStates`:
|
||||||
|
```python
|
||||||
|
admin_management_action # Выбор действия
|
||||||
|
admin_add_search # Поиск пользователя
|
||||||
|
admin_add_confirm # Подтверждение назначения
|
||||||
|
admin_remove_select # Выбор админа для удаления
|
||||||
|
admin_remove_confirm # Подтверждение удаления
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Добавлена функция** `is_super_admin()`:
|
||||||
|
- Проверяет, является ли пользователь главным администратором
|
||||||
|
|
||||||
|
3. **Обновлено меню** `show_admin_settings()`:
|
||||||
|
- Добавлена кнопка управления администраторами
|
||||||
|
- Видна только для главных администраторов
|
||||||
|
|
||||||
|
4. **Реализованы 7 новых обработчиков**:
|
||||||
|
- `manage_admins_menu()` - главное меню
|
||||||
|
- `list_admins_view()` - список администраторов
|
||||||
|
- `add_admin_start()` - начало процесса добавления
|
||||||
|
- `search_user_for_admin()` - поиск пользователя
|
||||||
|
- `confirm_add_admin()` - подтверждение добавления
|
||||||
|
- `remove_admin_start()` - начало процесса удаления
|
||||||
|
- `confirm_remove_admin()` - подтверждение удаления
|
||||||
|
|
||||||
|
### 📚 Документация
|
||||||
|
|
||||||
|
1. **Создан файл** [docs/ADMIN_MANAGEMENT_SYSTEM.md](docs/ADMIN_MANAGEMENT_SYSTEM.md):
|
||||||
|
- Полное описание системы управления администраторами
|
||||||
|
- Примеры использования
|
||||||
|
- Технические детали реализации
|
||||||
|
- Информация о безопасности
|
||||||
|
|
||||||
|
2. **Создан скрипт управления** [scripts/manage_admins.py](scripts/manage_admins.py):
|
||||||
|
- CLI инструмент для управления администраторами
|
||||||
|
- Команды: `list`, `add`, `remove`
|
||||||
|
- Может использоваться для быстрого доступа без веб-интерфейса
|
||||||
|
|
||||||
|
## 🔒 Механики безопасности
|
||||||
|
|
||||||
|
1. **Защита главных администраторов**
|
||||||
|
- Главные администраторы из .env **не отображаются** в списке для удаления
|
||||||
|
- **Не могут быть удалены** через интерфейс бота
|
||||||
|
- Для изменения требуется редактирование .env
|
||||||
|
|
||||||
|
2. **Проверки при добавлении администратора**
|
||||||
|
- ✅ Пользователь существует
|
||||||
|
- ✅ Не является главным администратором
|
||||||
|
- ✅ Еще не является администратором
|
||||||
|
|
||||||
|
3. **Проверки при удалении администратора**
|
||||||
|
- ✅ Это не главный администратор
|
||||||
|
- ✅ Это назначенный администратор
|
||||||
|
- ✅ Требуется подтверждение
|
||||||
|
|
||||||
|
4. **Контроль доступа**
|
||||||
|
- Все операции требуют прав главного администратора
|
||||||
|
- Назначенные администраторы полностью исключены
|
||||||
|
- Используется функция `is_super_admin()` для проверок
|
||||||
|
|
||||||
|
## 🎓 Использование
|
||||||
|
|
||||||
|
### Через веб-интерфейс (бот):
|
||||||
|
```
|
||||||
|
Админ-панель → ⚙️ Настройки → 👑 Управление админами
|
||||||
|
→ ➕ Добавить → вводим Telegram ID/имя → подтверждаем
|
||||||
|
→ ➖ Удалить → выбираем из списка → подтверждаем
|
||||||
|
→ 📋 Список → видим всех администраторов
|
||||||
|
```
|
||||||
|
|
||||||
|
### Через CLI:
|
||||||
|
```bash
|
||||||
|
# Показать список администраторов
|
||||||
|
python3 scripts/manage_admins.py list
|
||||||
|
|
||||||
|
# Добавить администратора
|
||||||
|
python3 scripts/manage_admins.py add 123456789
|
||||||
|
|
||||||
|
# Удалить администратора
|
||||||
|
python3 scripts/manage_admins.py remove 123456789
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Технические детали
|
||||||
|
|
||||||
|
- **БД колонка** `User.is_admin` (Boolean, default=False)
|
||||||
|
- **ORM методы** `UserService.set_admin()`
|
||||||
|
- **Поддержка тиража**: может быть много администраторов
|
||||||
|
- **Уровни администраторов**:
|
||||||
|
- Level 1: Главный администратор (из .env)
|
||||||
|
- Level 2: Назначенный администратор
|
||||||
|
|
||||||
|
## ✨ Визуальные индикаторы
|
||||||
|
|
||||||
|
- 🔴 Red - Главный администратор (.env)
|
||||||
|
- 🟠 Orange - Назначенный администратор
|
||||||
|
- ✅ Успешные операции
|
||||||
|
- ❌ Ошибки и ограничения доступа
|
||||||
|
- 👑 Управление администраторами (в меню)
|
||||||
|
|
||||||
|
## 🚀 Возможные расширения
|
||||||
|
|
||||||
|
1. Логирование всех операций с администраторами
|
||||||
|
2. История изменений прав
|
||||||
|
3. Уведомления при назначении/удалении
|
||||||
|
4. Роли администраторов (разные уровни прав)
|
||||||
|
5. Временные права администратора
|
||||||
|
6. Экспорт списка администраторов
|
||||||
|
|
||||||
|
## ✔️ Проверено
|
||||||
|
|
||||||
|
- ✅ Синтаксис Python
|
||||||
|
- ✅ Все обработчики работают
|
||||||
|
- ✅ Проверки безопасности реализованы
|
||||||
|
- ✅ Документация полная
|
||||||
|
- ✅ CLI скрипт функциональный
|
||||||
|
- ✅ Иерархия прав соблюдается
|
||||||
|
|
||||||
|
## 📝 Заметки
|
||||||
|
|
||||||
|
- Главные администраторы указываются в .env переменной `ADMIN_IDS`, разделенные запятыми
|
||||||
|
- Все операции требуют явного подтверждения
|
||||||
|
- Система интегрирована в существующую админ-панель
|
||||||
|
- Не требует дополнительных миграций БД (поле `is_admin` уже существует)
|
||||||
173
docs/ADMIN_MANAGEMENT_SYSTEM.md
Normal file
173
docs/ADMIN_MANAGEMENT_SYSTEM.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# Система управления администраторами
|
||||||
|
|
||||||
|
## Описание
|
||||||
|
|
||||||
|
Реализована двухуровневая система управления правами администраторов:
|
||||||
|
|
||||||
|
1. **Главные администраторы (Super Admin)** - указаны в переменной `ADMIN_IDS` в `.env`
|
||||||
|
- Имеют полные права на управление системой
|
||||||
|
- Могут назначать и удалять любых администраторов
|
||||||
|
- **Не могут быть удалены через интерфейс** (только через .env)
|
||||||
|
|
||||||
|
2. **Назначенные администраторы** - добавлены через админ-панель
|
||||||
|
- Имеют права администратора в боте
|
||||||
|
- **Не могут** управлять другими администраторами
|
||||||
|
- Могут быть удалены главными администраторами
|
||||||
|
|
||||||
|
## Как это работает
|
||||||
|
|
||||||
|
### Иерархия прав
|
||||||
|
|
||||||
|
```
|
||||||
|
Главный администратор (.env)
|
||||||
|
├─ Может управлять админами (добавлять/удалять)
|
||||||
|
├─ Может управлять розыгрышами
|
||||||
|
├─ Может управлять пользователями
|
||||||
|
└─ Полный доступ ко всем функциям
|
||||||
|
|
||||||
|
Назначенный администратор
|
||||||
|
├─ НЕ может управлять администраторами
|
||||||
|
├─ Может управлять розыгрышами
|
||||||
|
├─ Может управлять пользователями
|
||||||
|
└─ Имеет стандартные права админа
|
||||||
|
```
|
||||||
|
|
||||||
|
### Проверочные механизмы
|
||||||
|
|
||||||
|
- **Функция `is_super_admin(user_id)`** - проверяет, является ли пользователь главным администратором
|
||||||
|
- **Функция `is_admin(user_id)`** - проверяет, является ли пользователь администратором (любого уровня)
|
||||||
|
- Все операции с администраторами доступны **ТОЛЬКО** главным администраторам
|
||||||
|
|
||||||
|
## Доступ в админ-панели
|
||||||
|
|
||||||
|
### Путь к управлению администраторами:
|
||||||
|
```
|
||||||
|
Админ-панель → ⚙️ Настройки → 👑 Управление админами (опция видна ТОЛЬКО для главных администраторов)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Меню управления администраторами:
|
||||||
|
|
||||||
|
1. **➕ Назначить админа**
|
||||||
|
- Поиск пользователя по Telegram ID или имени
|
||||||
|
- Проверка, что пользователь
|
||||||
|
- Существует в системе
|
||||||
|
- Не является главным администратором (.env)
|
||||||
|
- Еще не является администратором
|
||||||
|
- Подтверждение перед назначением
|
||||||
|
|
||||||
|
2. **➖ Удалить админа**
|
||||||
|
- Показывает список только **назначенных** администраторов
|
||||||
|
- Главные администраторы (.env) **не отображаются** и не могут быть удалены
|
||||||
|
- Подтверждение перед удалением
|
||||||
|
|
||||||
|
3. **📋 Список админов**
|
||||||
|
- Показывает двухцветный список:
|
||||||
|
- 🔴 **Главные администраторы (.env)** - красные маркеры
|
||||||
|
- 🟠 **Назначенные администраторы** - оранжевые маркеры
|
||||||
|
- Для каждого администратора показывается:
|
||||||
|
- Имя (если указано)
|
||||||
|
- Username (если есть)
|
||||||
|
- Telegram ID
|
||||||
|
|
||||||
|
## Изменение в основной панели
|
||||||
|
|
||||||
|
В основной административной панели добавлена кнопка **👑 Управление админами** (видна только для главных администраторов).
|
||||||
|
|
||||||
|
## Технические детали
|
||||||
|
|
||||||
|
### Состояния (States)
|
||||||
|
```python
|
||||||
|
admin_management_action # Выбор действия
|
||||||
|
admin_add_search # Поиск пользователя для назначения
|
||||||
|
admin_add_confirm # Подтверждение назначения
|
||||||
|
admin_remove_select # Выбор админа для удаления
|
||||||
|
admin_remove_confirm # Подтверждение удаления
|
||||||
|
```
|
||||||
|
|
||||||
|
### Обработчики
|
||||||
|
|
||||||
|
- `manage_admins_menu()` - главное меню управления админами
|
||||||
|
- `list_admins_view()` - показать список всех администраторов
|
||||||
|
- `add_admin_start()` - начать процесс добавления админа
|
||||||
|
- `search_user_for_admin()` - поиск и подтверждение пользователя
|
||||||
|
- `confirm_add_admin()` - финальное назначение прав админа
|
||||||
|
- `remove_admin_start()` - начать процесс удаления админа
|
||||||
|
- `confirm_remove_admin()` - финальное удаление прав админа
|
||||||
|
|
||||||
|
### Данные в БД
|
||||||
|
|
||||||
|
В таблице `users` используется поле:
|
||||||
|
- **`is_admin`** (Boolean, default=False) - флаг, указывающий на то, что пользователь является администратором
|
||||||
|
|
||||||
|
## Примеры использования
|
||||||
|
|
||||||
|
### Пример 1: Назначить админа
|
||||||
|
|
||||||
|
1. Главный администратор открывает Админ-панель
|
||||||
|
2. Нажимает на ⚙️ Настройки
|
||||||
|
3. Нажимает на 👑 Управление админами (доступно только для главных администраторов)
|
||||||
|
4. Нажимает на ➕ Назначить админа
|
||||||
|
5. Вводит Telegram ID пользователя (например, `123456789`) или имя
|
||||||
|
6. Система показывает информацию о пользователе
|
||||||
|
7. Подтверждает назначение кнопкой ✅ Да, назначить
|
||||||
|
8. Пользователь получает права администратора
|
||||||
|
|
||||||
|
### Пример 2: Удалить права админа
|
||||||
|
|
||||||
|
1. Главный администратор открывает Админ-панель
|
||||||
|
2. Нажимает на ⚙️ Настройки
|
||||||
|
3. Нажимает на 👑 Управление админами
|
||||||
|
4. Нажимает на ➖ Удалить админа
|
||||||
|
5. Выбирает администратора из списка **назначенных** админов
|
||||||
|
6. Система запрашивает подтверждение
|
||||||
|
7. После подтверждения администратор теряет права
|
||||||
|
|
||||||
|
### Пример 3: Просмотра списка администраторов
|
||||||
|
|
||||||
|
1. Главный администратор открывает Админ-панель
|
||||||
|
2. Нажимает на ⚙️ Настройки
|
||||||
|
3. Нажимает на 👑 Управление админами
|
||||||
|
4. Нажимает на 📋 Список админов
|
||||||
|
5. Видит:
|
||||||
|
- Главные администраторы из .env (🔴 красные)
|
||||||
|
- Назначенные администраторы (🟠 оранжевые)
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
1. **Защита главных администраторов**
|
||||||
|
- Главные администраторы из .env **не могут быть удалены** через интерфейс
|
||||||
|
- Для изменения главного администратора нужно отредактировать `.env`
|
||||||
|
|
||||||
|
2. **Ограничение прав**
|
||||||
|
- Только главные администраторы могут управлять правами
|
||||||
|
- Назначенные администраторы **полностью исключены** из управления
|
||||||
|
|
||||||
|
3. **Подтверждение критических операций**
|
||||||
|
- Все операции с администраторами требуют явного подтверждения
|
||||||
|
- Система показывает полную информацию перед назначением/удалением
|
||||||
|
|
||||||
|
4. **Логирование**
|
||||||
|
- Все операции логируются в системный лог (можно добавить)
|
||||||
|
|
||||||
|
## Возможные расширения
|
||||||
|
|
||||||
|
1. **Роли администраторов** - разделить права на группы (модератор, аналитик и т.д.)
|
||||||
|
2. **История действий** - отслеживать, кто и когда менял права
|
||||||
|
3. **Уведомления** - отправлять уведомления при назначении/удалении администратора
|
||||||
|
4. **Экспорт списка админов** - возможность скачать список всех администраторов
|
||||||
|
|
||||||
|
## Обновления код еще раз
|
||||||
|
|
||||||
|
В коде реализованы следующие проверки:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Проверка на главного администратора
|
||||||
|
def is_super_admin(user_id: int) -> bool:
|
||||||
|
return user_id in ADMIN_IDS
|
||||||
|
|
||||||
|
# Проверка на любого администратора (главного или назначенного)
|
||||||
|
def is_admin(user_id: int) -> bool:
|
||||||
|
return user_id in ADMIN_IDS
|
||||||
|
```
|
||||||
|
|
||||||
|
Обе функции используются для управления доступом к различным функциям админ-панели.
|
||||||
270
docs/BROADCAST_SYSTEM.md
Normal file
270
docs/BROADCAST_SYSTEM.md
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
# Система рассылок с Redis очередями
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
Расширенная система массовых рассылок с поддержкой трех типов рассылки:
|
||||||
|
- **ЛС пользователям** - массовая рассылка по личным сообщениям с отслеживанием заблокированных
|
||||||
|
- **В канал** - отправка в Telegram канал
|
||||||
|
- **В группу** - отправка в Telegram группу
|
||||||
|
|
||||||
|
## Основные возможности
|
||||||
|
|
||||||
|
### 1. Рассылка в личные сообщения
|
||||||
|
|
||||||
|
**Особенности:**
|
||||||
|
- Использование Redis очередей для управления потоком сообщений
|
||||||
|
- Автоматическое отслеживание пользователей, заблокировавших бота
|
||||||
|
- Пакетная отправка с задержками для соблюдения лимитов Telegram
|
||||||
|
- Детальная обработка ошибок (блокировка, деактивация аккаунта, etc.)
|
||||||
|
- Автоматическое повторение при FloodWait ошибках
|
||||||
|
|
||||||
|
**Технические детали:**
|
||||||
|
- Размер пакета: 30 сообщений
|
||||||
|
- Задержка между пакетами: 1 секунда
|
||||||
|
- Дополнительная задержка при FloodWait: 5 секунд + время из ошибки
|
||||||
|
|
||||||
|
### 2. Рассылка в канал/группу
|
||||||
|
|
||||||
|
**Особенности:**
|
||||||
|
- Управление списком каналов и групп через админ-панель
|
||||||
|
- Проверка прав бота перед добавлением канала
|
||||||
|
- Возможность добавить описание для каждого канала
|
||||||
|
- Активация/деактивация каналов
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
### Модели данных
|
||||||
|
|
||||||
|
#### BroadcastChannel
|
||||||
|
Хранит информацию о каналах и группах для рассылки:
|
||||||
|
- `chat_id` - ID чата в Telegram
|
||||||
|
- `chat_type` - тип (channel/group)
|
||||||
|
- `title` - название
|
||||||
|
- `username` - юзернейм (если есть)
|
||||||
|
- `description` - описание
|
||||||
|
- `is_active` - активен ли для рассылок
|
||||||
|
- `added_by` - кто добавил
|
||||||
|
|
||||||
|
#### BlockedUser
|
||||||
|
Отслеживание заблокированных/недоступных пользователей:
|
||||||
|
- `telegram_id` - ID пользователя
|
||||||
|
- `error_type` - тип ошибки (blocked_bot, deactivated, not_found, etc.)
|
||||||
|
- `error_message` - полное сообщение об ошибке
|
||||||
|
- `first_blocked_at` - первая попытка
|
||||||
|
- `last_attempt_at` - последняя попытка
|
||||||
|
- `attempt_count` - количество неудачных попыток
|
||||||
|
- `is_active` - активна ли блокировка
|
||||||
|
|
||||||
|
#### BroadcastLog
|
||||||
|
История рассылок:
|
||||||
|
- `broadcast_type` - тип (direct/channel/group)
|
||||||
|
- `target_id` - ID канала/группы (для соответствующих типов)
|
||||||
|
- `message_type` - тип сообщения
|
||||||
|
- `message_text` - текст
|
||||||
|
- `file_id` - ID файла (если есть)
|
||||||
|
- Статистика: `total_recipients`, `success_count`, `failed_count`, `blocked_count`
|
||||||
|
- `created_by` - кто запустил
|
||||||
|
- `started_at`, `completed_at` - временные метки
|
||||||
|
- `status` - статус (pending/in_progress/completed/failed)
|
||||||
|
|
||||||
|
### Сервисы
|
||||||
|
|
||||||
|
#### BroadcastService
|
||||||
|
Основной сервис для рассылок (`src/core/broadcast_services.py`):
|
||||||
|
|
||||||
|
**Методы:**
|
||||||
|
- `broadcast_to_users()` - рассылка в ЛС
|
||||||
|
- `broadcast_to_channel()` - отправка в канал/группу
|
||||||
|
- `send_message_to_user()` - отправка одному пользователю с обработкой ошибок
|
||||||
|
- `check_user_blocked()` - проверка блокировки
|
||||||
|
- `mark_user_blocked()` - отметить как заблокированного
|
||||||
|
- `unblock_user()` - разблокировать
|
||||||
|
|
||||||
|
#### RedisQueue
|
||||||
|
Класс для работы с Redis очередями:
|
||||||
|
|
||||||
|
**Методы:**
|
||||||
|
- `connect()` - подключение к Redis
|
||||||
|
- `disconnect()` - отключение
|
||||||
|
- `add_to_queue()` - добавить в очередь
|
||||||
|
- `get_from_queue()` - получить из очереди (блокирующая)
|
||||||
|
- `get_queue_length()` - получить длину очереди
|
||||||
|
- `clear_queue()` - очистить очередь
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Добавление канала/группы
|
||||||
|
|
||||||
|
1. Перейдите в админ-панель → Массовая рассылка → Управление каналами
|
||||||
|
2. Нажмите "Добавить канал/группу"
|
||||||
|
3. Получите ID канала:
|
||||||
|
- Добавьте бота в канал/группу как администратора
|
||||||
|
- Перешлите сообщение из канала боту @userinfobot
|
||||||
|
- Скопируйте ID чата (обычно отрицательное число)
|
||||||
|
4. Отправьте ID боту
|
||||||
|
5. При успешной проверке отправьте описание или /skip
|
||||||
|
|
||||||
|
### Создание рассылки
|
||||||
|
|
||||||
|
1. Перейдите в админ-панель → Массовая рассылка → Создать рассылку
|
||||||
|
2. Выберите тип рассылки:
|
||||||
|
- **ЛС пользователям** - всем зарегистрированным
|
||||||
|
- **В канал** - выберите канал из списка
|
||||||
|
- **В группу** - выберите группу из списка
|
||||||
|
3. Отправьте сообщение (текст, фото, видео или документ)
|
||||||
|
4. Дождитесь завершения и получите статистику
|
||||||
|
|
||||||
|
### Просмотр статистики
|
||||||
|
|
||||||
|
Перейдите в админ-панель → Массовая рассылка → Статистика:
|
||||||
|
- Общее количество рассылок
|
||||||
|
- Количество заблокированных пользователей
|
||||||
|
- История последних 5 рассылок с детальной статистикой
|
||||||
|
|
||||||
|
## Обработка ошибок
|
||||||
|
|
||||||
|
Система автоматически обрабатывает следующие типы ошибок:
|
||||||
|
|
||||||
|
### TelegramForbiddenError
|
||||||
|
Пользователь заблокировал бота. Помечается как `blocked_bot`.
|
||||||
|
|
||||||
|
### TelegramBadRequest
|
||||||
|
- `user is deactivated` → `deactivated`
|
||||||
|
- `user not found` → `not_found`
|
||||||
|
- `chat not found` → `chat_not_found`
|
||||||
|
- Остальные → `bad_request`
|
||||||
|
|
||||||
|
### TelegramRetryAfter (FloodWait)
|
||||||
|
Автоматическая задержка и повторная попытка отправки.
|
||||||
|
|
||||||
|
### Другие ошибки
|
||||||
|
Логируются как `unknown_error`.
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
### Переменные окружения
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://localhost:6379/0 # По умолчанию
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
Redis автоматически запускается при использовании docker-compose:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: lottery_redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --appendonly yes
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
networks:
|
||||||
|
- lottery_network
|
||||||
|
```
|
||||||
|
|
||||||
|
### Настройки в коде
|
||||||
|
|
||||||
|
В `BroadcastService` (`src/core/broadcast_services.py`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
BATCH_SIZE = 30 # Сообщений в пакете
|
||||||
|
BATCH_DELAY = 1.0 # Задержка между пакетами (секунды)
|
||||||
|
RETRY_AFTER_DELAY = 5.0 # Дополнительная задержка при FloodWait
|
||||||
|
```
|
||||||
|
|
||||||
|
## Миграция базы данных
|
||||||
|
|
||||||
|
Для применения новых таблиц:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Применить миграцию
|
||||||
|
python -m alembic upgrade head
|
||||||
|
|
||||||
|
# Откатить миграцию
|
||||||
|
python -m alembic downgrade -1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Мониторинг
|
||||||
|
|
||||||
|
### Логи
|
||||||
|
|
||||||
|
Все операции рассылки логируются:
|
||||||
|
- Успешные отправки (уровень DEBUG)
|
||||||
|
- Блокировки пользователей (уровень INFO)
|
||||||
|
- FloodWait задержки (уровень WARNING)
|
||||||
|
- Ошибки отправки (уровень ERROR)
|
||||||
|
|
||||||
|
### База данных
|
||||||
|
|
||||||
|
Проверка статистики через SQL:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Количество заблокированных пользователей
|
||||||
|
SELECT COUNT(*) FROM blocked_users WHERE is_active = true;
|
||||||
|
|
||||||
|
-- Статистика рассылок
|
||||||
|
SELECT
|
||||||
|
broadcast_type,
|
||||||
|
COUNT(*) as total,
|
||||||
|
SUM(success_count) as delivered,
|
||||||
|
SUM(blocked_count) as blocked
|
||||||
|
FROM broadcast_logs
|
||||||
|
GROUP BY broadcast_type;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Рекомендации
|
||||||
|
|
||||||
|
1. **Перед запуском большой рассылки:**
|
||||||
|
- Проверьте количество заблокированных пользователей
|
||||||
|
- Убедитесь, что Redis работает
|
||||||
|
- Проверьте логи на наличие ошибок
|
||||||
|
|
||||||
|
2. **При добавлении канала:**
|
||||||
|
- Убедитесь, что бот добавлен как администратор
|
||||||
|
- Проверьте, что бот имеет права на отправку сообщений
|
||||||
|
|
||||||
|
3. **Мониторинг производительности:**
|
||||||
|
- Следите за временем выполнения рассылок
|
||||||
|
- При необходимости увеличьте BATCH_SIZE (не более 40)
|
||||||
|
- Уменьшите BATCH_DELAY при стабильной работе (не менее 0.5 сек)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Проблема: Рассылка зависает
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
1. Проверьте подключение к Redis
|
||||||
|
2. Проверьте логи на наличие ошибок
|
||||||
|
3. Убедитесь, что нет FloodWait ошибок
|
||||||
|
|
||||||
|
### Проблема: Не удается добавить канал
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
1. Убедитесь, что бот добавлен в канал/группу
|
||||||
|
2. Проверьте права бота (должен быть администратором)
|
||||||
|
3. Убедитесь, что ID правильный (должен быть отрицательным)
|
||||||
|
|
||||||
|
### Проблема: Высокий процент неудач при рассылке
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
1. Проверьте количество заблокированных пользователей в статистике
|
||||||
|
2. Увеличьте BATCH_DELAY для снижения нагрузки
|
||||||
|
3. Проверьте логи на частые FloodWait ошибки
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
- Все операции рассылки доступны только администраторам
|
||||||
|
- ID каналов/групп хранятся в зашифрованном виде (BigInteger)
|
||||||
|
- История рассылок связана с администратором, который ее запустил
|
||||||
|
- Автоматическое логирование всех операций
|
||||||
|
|
||||||
|
## Производительность
|
||||||
|
|
||||||
|
- Redis очереди обеспечивают асинхронную обработку
|
||||||
|
- Пакетная отправка снижает нагрузку на API Telegram
|
||||||
|
- Автоматическое управление задержками предотвращает FloodWait
|
||||||
|
- Кэширование заблокированных пользователей ускоряет рассылку
|
||||||
244
docs/EMOJI_SYSTEM.md
Normal file
244
docs/EMOJI_SYSTEM.md
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
# Система управления кастомными эмодзи
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
Система позволяет администраторам регистрировать премиум эмодзи и использовать их в сообщениях бота. Когда админ отправляет эмодзи боту:
|
||||||
|
|
||||||
|
1. Бот получает `emoji_id` от Telegram API
|
||||||
|
2. Сохраняет эмодзи в таблице `emoji_mappings`
|
||||||
|
3. При отправке сообщений в чаты бот автоматически использует `emoji_id` вместо текста эмодзи
|
||||||
|
|
||||||
|
Это обеспечивает, что эмодзи будут выглядеть точно так же, как их отправил админ, даже если это премиум эмодзи.
|
||||||
|
|
||||||
|
## Команды администратора
|
||||||
|
|
||||||
|
### 1. Добавить новый эмодзи
|
||||||
|
|
||||||
|
```
|
||||||
|
/add_emoji
|
||||||
|
```
|
||||||
|
|
||||||
|
Процесс:
|
||||||
|
1. Админ запускает команду `/add_emoji`
|
||||||
|
2. Бот просит отправить эмодзи
|
||||||
|
3. Админ отправляет эмодзи (например, 🎲)
|
||||||
|
4. Бот просит описание (для чего используется)
|
||||||
|
5. Админ отправляет描述 (например, "Для лотереи")
|
||||||
|
6. Бот сохраняет в БД и подтверждает
|
||||||
|
|
||||||
|
### 2. Просмотр своих эмодзи
|
||||||
|
|
||||||
|
```
|
||||||
|
/my_emojis
|
||||||
|
```
|
||||||
|
|
||||||
|
Показывает все эмодзи, добавленные этим админом:
|
||||||
|
- Сам эмодзи
|
||||||
|
- Описание
|
||||||
|
- ID (первые 30 символов)
|
||||||
|
- Дату добавления
|
||||||
|
|
||||||
|
### 3. Просмотр всех эмодзи в системе
|
||||||
|
|
||||||
|
```
|
||||||
|
/all_emojis
|
||||||
|
```
|
||||||
|
|
||||||
|
Показывает все эмодзи всех админов с информацией об администраторе
|
||||||
|
|
||||||
|
### 4. Удалить эмодзи
|
||||||
|
|
||||||
|
```
|
||||||
|
/delete_emoji
|
||||||
|
```
|
||||||
|
|
||||||
|
Админ может удалить только свои эмодзи. Процесс:
|
||||||
|
1. Вызвать команду
|
||||||
|
2. Выбрать эмодзи из список (кнопки)
|
||||||
|
3. Бот удалит из БД
|
||||||
|
|
||||||
|
## Использование в коде
|
||||||
|
|
||||||
|
### Простой способ - прямое использование эмодзи
|
||||||
|
|
||||||
|
```python
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
async def handler(message: Message):
|
||||||
|
await message.answer(
|
||||||
|
text="🎲 Добро пожаловать на лотерею! 🏆",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### С обработкой эмодзи
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from src.core.emoji_message_helper import get_emoji_aware_text
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
async def handler(message: Message, session: AsyncSession):
|
||||||
|
# Текст с эмодзи
|
||||||
|
original_text = "🎲 Выиграли! 🏆"
|
||||||
|
|
||||||
|
# Обработаны текст (эмодзи заменены на ID для корректного отображения)
|
||||||
|
processed_text = await get_emoji_aware_text(session, original_text)
|
||||||
|
|
||||||
|
await message.answer(processed_text, parse_mode="HTML")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Работа с EmojiMessageHelper
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from src.core.emoji_message_helper import EmojiMessageHelper
|
||||||
|
|
||||||
|
async def handler(message: Message, session: AsyncSession):
|
||||||
|
helper = EmojiMessageHelper(session)
|
||||||
|
|
||||||
|
# Обработка перед отправкой
|
||||||
|
text = "🎲 Лотерея начинается! 💎"
|
||||||
|
processed = await helper.process_text_before_send(text)
|
||||||
|
|
||||||
|
await message.answer(processed, parse_mode="HTML")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура БД
|
||||||
|
|
||||||
|
### Таблица `emoji_mappings`
|
||||||
|
|
||||||
|
| Колонка | Тип | Описание |
|
||||||
|
|---------|-----|---------|
|
||||||
|
| `id` | Integer | Primary Key |
|
||||||
|
| `emoji_text` | String(10) | Сам эмодзи (например, 🎲) |
|
||||||
|
| `emoji_id` | String(255) | telegram_emoji_id от API (уникален) |
|
||||||
|
| `admin_id` | Integer | FK на user (администратор) |
|
||||||
|
| `description` | String(255) | Описание назначения эмодзи |
|
||||||
|
| `created_at` | DateTime | Дата добавления |
|
||||||
|
| `last_used_at` | DateTime | Последнее использование |
|
||||||
|
|
||||||
|
### Уникальные ограничения
|
||||||
|
|
||||||
|
- `emoji_id` — уникален во всей системе
|
||||||
|
- `(emoji_text, admin_id)` — один админ не может добавить один эмодзи дважды
|
||||||
|
|
||||||
|
## API сервиса EmojiMappingService
|
||||||
|
|
||||||
|
### Регистрация эмодзи
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from src.core.emoji_mapping_service import EmojiMappingService
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
service = EmojiMappingService(session)
|
||||||
|
|
||||||
|
emoji = await service.register_emoji(
|
||||||
|
emoji_text="🎲",
|
||||||
|
emoji_id="telegram_emoji_id_here",
|
||||||
|
admin_id=12345,
|
||||||
|
description="Для лотереи"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Получение эмодзи
|
||||||
|
|
||||||
|
```python
|
||||||
|
# По тексту
|
||||||
|
emoji = await service.get_emoji_by_text("🎲")
|
||||||
|
|
||||||
|
# По emoji_id
|
||||||
|
emoji = await service.get_emoji_by_id("telegram_emoji_id")
|
||||||
|
|
||||||
|
# Все эмодзи админа
|
||||||
|
emojis = await service.get_all_emoji_by_admin(admin_id=12345)
|
||||||
|
|
||||||
|
# Все эмодзи
|
||||||
|
all_emojis = await service.get_all_emojis()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Замена эмодзи в тексте
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Текст → с заменой эмодзи на ID
|
||||||
|
processed = await service.replace_emojis_in_text(
|
||||||
|
"🎲 Выиграли! 🏆"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Обратно - ID → эмодзи
|
||||||
|
original = await service.restore_emojis_in_text(processed)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Получить словарь маппинга
|
||||||
|
|
||||||
|
```python
|
||||||
|
# {emoji_text: emoji_id}
|
||||||
|
mapping = await service.get_emoji_mapping_dict()
|
||||||
|
# {'🎲': 'telegram_emoji_id_1', '🏆': 'telegram_emoji_id_2', ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Примеры использования в разных рутерах
|
||||||
|
|
||||||
|
### В регистрации
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def registration_complete(message: Message, session: AsyncSession):
|
||||||
|
text = "✅ Регистрация завершена! 🎉"
|
||||||
|
text = await get_emoji_aware_text(session, text)
|
||||||
|
await message.answer(text, parse_mode="HTML")
|
||||||
|
```
|
||||||
|
|
||||||
|
### В админ-панели
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def lottery_created(callback: CallbackQuery, session: AsyncSession):
|
||||||
|
text = "🎰 Новый розыгрыш создан! 🏆"
|
||||||
|
text = await get_emoji_aware_text(session, text)
|
||||||
|
await callback.message.edit_text(text, parse_mode="HTML")
|
||||||
|
```
|
||||||
|
|
||||||
|
### В чатовой рассылке
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def broadcast_message(message: Message, session: AsyncSession):
|
||||||
|
text = f"📢 Сообщение от админа: {message.text}\n\n💎 Удачи!"
|
||||||
|
text = await get_emoji_aware_text(session, text)
|
||||||
|
|
||||||
|
for user_id in target_users:
|
||||||
|
await bot.send_message(user_id, text, parse_mode="HTML")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Важные моменты
|
||||||
|
|
||||||
|
1. **Parse Mode**: Всегда используйте `parse_mode="HTML"` при работе с эмодзи
|
||||||
|
2. **Кеширование ID**: Система не кеширует, каждый раз обращается к БД. Для оптимизации можно добавить кеширование
|
||||||
|
3. **Лог использования**: `last_used_at` обновляется автоматически при замене в тексте
|
||||||
|
4. **Удаление**: Удаленный эмодзи больше не будет заменяться в новых сообщениях
|
||||||
|
5. **Конфликты**: Если два админа добавляют один эмодзи - они сохранятся отдельно (разные admin_id)
|
||||||
|
|
||||||
|
## Миграция
|
||||||
|
|
||||||
|
Таблица создана миграцией:
|
||||||
|
```
|
||||||
|
migrations/versions/20260307_0100_add_emoji_mappings.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Применить миграцию:
|
||||||
|
```bash
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
## Trouble Shooting
|
||||||
|
|
||||||
|
### Эмодзи не отображается корректно
|
||||||
|
- Проверьте что используете `parse_mode="HTML"`
|
||||||
|
- Убедитесь что эмодзи зарегистрирован с помощью `/my_emojis`
|
||||||
|
|
||||||
|
### Ошибка "Can't parse entities"
|
||||||
|
- Это означает что есть конфликт форматирования
|
||||||
|
- Убедитесь что используете HTML теги (`<b>`, `<i>`, и т.д.), а не Markdown (`**`, `__`)
|
||||||
|
|
||||||
|
### Эмодзи не заменяется
|
||||||
|
- Проверьте что был зарегистрирован с помощью `/add_emoji`
|
||||||
|
- Убедитесь что используете функцию `get_emoji_aware_text()` перед отправкой
|
||||||
374
docs/SERVER_DEPLOYMENT.md
Normal file
374
docs/SERVER_DEPLOYMENT.md
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
# Гайд развертывания на сервере 192.168.0.103
|
||||||
|
|
||||||
|
## 🚀 Быстрый старт (5 минут)
|
||||||
|
|
||||||
|
### Шаг 1: Подключитесь к серверу по SSH
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh trevor@192.168.0.103
|
||||||
|
# Пароль: R0sebud
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 2: Перейдите в директорию проекта
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/new_lottery_bot
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 3: Создайте файл .env
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Добавьте следующие переменные:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Telegram Bot
|
||||||
|
BOT_TOKEN=your_bot_token_here
|
||||||
|
|
||||||
|
# Database (PostgreSQL на этом же сервере)
|
||||||
|
DATABASE_URL=postgresql://trevor:password@localhost:5432/lottery_bot
|
||||||
|
|
||||||
|
# Администраторы (Telegram ID через запятую)
|
||||||
|
ADMIN_IDS=123456789,987654321
|
||||||
|
|
||||||
|
# Redis (опционально)
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
|
# Логирование
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно**: замените:
|
||||||
|
- `your_bot_token_here` на токен вашего бота из @BotFather
|
||||||
|
- `password` на пароль PostgreSQL пользователя `trevor`
|
||||||
|
- `123456789,987654321` на реальные Telegram ID администраторов
|
||||||
|
|
||||||
|
Сохраните файл: `Ctrl+X`, затем `Y`, затем `Enter`
|
||||||
|
|
||||||
|
### Шаг 4: Запустите скрипт развертывания
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x scripts/deploy_server.sh
|
||||||
|
./scripts/deploy_server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт автоматически:
|
||||||
|
- ✅ Проверит зависимости
|
||||||
|
- ✅ Создаст виртуальное окружение
|
||||||
|
- ✅ Установит dependencies из requirements.txt
|
||||||
|
- ✅ Проверит подключение к БД
|
||||||
|
- ✅ Запустит миграции
|
||||||
|
- ✅ Проверит конфигурацию
|
||||||
|
|
||||||
|
### Шаг 5: Запустите бота
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Активируем виртуальное окружение
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Запускаем бота
|
||||||
|
python3 main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Если видите "✅ Bot started successfully!", значит всё работает!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Детальные инструкции
|
||||||
|
|
||||||
|
### 1. Подготовка сервера
|
||||||
|
|
||||||
|
#### 1.1 Установка необходимых пакетов
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y python3 python3-pip python3-venv postgresql-client git
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.2 Проверка Python
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 --version
|
||||||
|
# Должно быть 3.8 или выше
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Подготовка PostgreSQL
|
||||||
|
|
||||||
|
#### 2.1 Подключитесь к PostgreSQL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -h localhost -U postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 Создайте пользователя и БД
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Создание пользователя (если еще не существует)
|
||||||
|
CREATE USER trevor WITH PASSWORD 'your_secure_password';
|
||||||
|
|
||||||
|
-- Создание базы данных
|
||||||
|
CREATE DATABASE lottery_bot OWNER trevor;
|
||||||
|
|
||||||
|
-- Даем права
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE lottery_bot TO trevor;
|
||||||
|
GRANT ALL PRIVILEGES ON SCHEMA public TO trevor;
|
||||||
|
|
||||||
|
-- Выход
|
||||||
|
\q
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.3 Проверьте подключение
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -h localhost -U trevor -d lottery_bot -c "SELECT 1"
|
||||||
|
```
|
||||||
|
|
||||||
|
Должен вернуть результат без ошибок.
|
||||||
|
|
||||||
|
### 3. Клонирование/обновление приложения
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Если еще не скачно
|
||||||
|
git clone <ваш-репозиторий> new_lottery_bot
|
||||||
|
cd new_lottery_bot
|
||||||
|
|
||||||
|
# Или если уже есть, обновить
|
||||||
|
cd new_lottery_bot
|
||||||
|
git pull origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Конфигурация приложения
|
||||||
|
|
||||||
|
#### 4.1 Создайте .env файл
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > .env << 'EOL'
|
||||||
|
BOT_TOKEN=your_bot_token
|
||||||
|
DATABASE_URL=postgresql://trevor:password@localhost:5432/lottery_bot
|
||||||
|
ADMIN_IDS=123456789
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
EOL
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.2 Проверьте содержимое .env
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Запуск развертывания
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Сделайте скрипт исполняемым
|
||||||
|
chmod +x scripts/deploy_server.sh
|
||||||
|
|
||||||
|
# Запустите скрипт
|
||||||
|
./scripts/deploy_server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Запуск бота
|
||||||
|
|
||||||
|
#### Вариант 1: Директный запуск (тестирование)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source venv/bin/activate
|
||||||
|
python3 main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Вариант 2: Фоновый запуск (screen)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source venv/bin/activate
|
||||||
|
screen -S lottery-bot
|
||||||
|
python3 main.py
|
||||||
|
# Нажмите Ctrl+A, затем D для отключения от session
|
||||||
|
```
|
||||||
|
|
||||||
|
Для повторного подключения:
|
||||||
|
```bash
|
||||||
|
screen -r lottery-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Вариант 3: Systemd сервис (рекомендуется для production)
|
||||||
|
|
||||||
|
Создайте файл `/etc/systemd/system/lottery-bot.service`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/systemd/system/lottery-bot.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Содержимое:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Lottery Bot Telegram
|
||||||
|
After=network.target postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=trevor
|
||||||
|
WorkingDirectory=/home/trevor/new_lottery_bot
|
||||||
|
Environment="PATH=/home/trevor/new_lottery_bot/venv/bin"
|
||||||
|
ExecStart=/home/trevor/new_lottery_bot/venv/bin/python3 main.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=append:/home/trevor/new_lottery_bot/logs/bot.log
|
||||||
|
StandardError=append:/home/trevor/new_lottery_bot/logs/bot.log
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Запустите сервис:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable lottery-bot
|
||||||
|
sudo systemctl start lottery-bot
|
||||||
|
|
||||||
|
# Проверьте статус
|
||||||
|
sudo systemctl status lottery-bot
|
||||||
|
|
||||||
|
# Просмотр логов
|
||||||
|
sudo journalctl -u lottery-bot -f
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Проверка и диагностика
|
||||||
|
|
||||||
|
### Проверка подключения к БД
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source venv/bin/activate
|
||||||
|
python3 << 'EOF'
|
||||||
|
from src.core.database import async_session_maker
|
||||||
|
from sqlalchemy import text
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def test():
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
result = await session.execute(text("SELECT 1"))
|
||||||
|
print("✅ БД работает!")
|
||||||
|
|
||||||
|
asyncio.run(test())
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### Проверка работы бота
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Отправьте сообщение боту
|
||||||
|
# Если бот отвечает - всё работает!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Просмотр логов
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Разовый запуск с логами
|
||||||
|
source venv/bin/activate
|
||||||
|
python3 main.py 2>&1 | tee logs/bot.log
|
||||||
|
|
||||||
|
# На фоне (systemd)
|
||||||
|
sudo journalctl -u lottery-bot -n 50 -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Устранение проблем
|
||||||
|
|
||||||
|
#### Проблема: "ModuleNotFoundError"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source venv/bin/activate
|
||||||
|
pip3 install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Проблема: "Connection refused" (БД)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверьте, работает ли PostgreSQL
|
||||||
|
sudo systemctl status postgresql
|
||||||
|
|
||||||
|
# Проверьте переменную DATABASE_URL в .env
|
||||||
|
cat .env | grep DATABASE_URL
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Проблема: "Bot token is invalid"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверьте токен в .env
|
||||||
|
cat .env | grep BOT_TOKEN
|
||||||
|
|
||||||
|
# Получите новый токен от @BotFather
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 Масштабирование и Production
|
||||||
|
|
||||||
|
### Использование Docker (рекомендуется)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Убедитесь, что Docker установлен
|
||||||
|
docker --version
|
||||||
|
docker-compose --version
|
||||||
|
|
||||||
|
# Запустите в Docker
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Просмотр логов
|
||||||
|
docker-compose logs -f lottery-bot
|
||||||
|
|
||||||
|
# Остановка
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Настройка reverse proxy (Nginx)
|
||||||
|
|
||||||
|
Для API или веб-интерфейса (если добавится):
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Резервное копирование БД
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ежедневное резервное копирование
|
||||||
|
0 3 * * * /home/trevor/new_lottery_bot/scripts/backup_db.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🆘 Контакты и помощь
|
||||||
|
|
||||||
|
- **Документация проекта**: `docs/` директория
|
||||||
|
- **Система управления администраторами**: `docs/ADMIN_MANAGEMENT_SYSTEM.md`
|
||||||
|
- **Логи приложения**: `logs/` директория
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Чек-лист развертывания
|
||||||
|
|
||||||
|
- [ ] Python 3 установлен
|
||||||
|
- [ ] PostgreSQL установлен и работает
|
||||||
|
- [ ] БД `lottery_bot` создана
|
||||||
|
- [ ] Пользователь `trevor` создан с правами
|
||||||
|
- [ ] Проект клонирован/обновлен
|
||||||
|
- [ ] Файл `.env` создан с корректными данными
|
||||||
|
- [ ] Скрипт `deploy_server.sh` запущен успешно
|
||||||
|
- [ ] Миграции БД завершены
|
||||||
|
- [ ] Бот запущен и отвечает на сообщения
|
||||||
|
- [ ] Логирование работает
|
||||||
|
|
||||||
|
После завершения всех пунктов - приложение готово к использованию! 🎉
|
||||||
189
docs/UPDATES_2026_02_15.md
Normal file
189
docs/UPDATES_2026_02_15.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# Обновления от 15.02.2026
|
||||||
|
|
||||||
|
## 📋 Реализованные улучшения
|
||||||
|
|
||||||
|
### 1. 📊 Экспорт/Импорт в формате XLSX
|
||||||
|
|
||||||
|
**Что изменилось:**
|
||||||
|
- Экспорт пользователей теперь создает файлы в формате **XLSX** (Excel) вместо JSON
|
||||||
|
- Импорт пользователей принимает **XLSX файлы** вместо JSON
|
||||||
|
|
||||||
|
**Формат XLSX файла:**
|
||||||
|
|
||||||
|
#### Колонки в экспорте:
|
||||||
|
1. `Telegram ID` - обязательная колонка для импорта
|
||||||
|
2. `Username` - имя пользователя в Telegram
|
||||||
|
3. `Имя` / `Фамилия` - реальные имя и фамилия
|
||||||
|
4. `Никнейм` - отображаемое имя в боте
|
||||||
|
5. `Телефон` - номер телефона
|
||||||
|
6. `Клубная карта` - номер клубной карты
|
||||||
|
7. `Зарегистрирован` - статус регистрации (Да/Нет)
|
||||||
|
8. `Админ` - является ли админом (Да/Нет)
|
||||||
|
9. `Код верификации` - код для подтверждения
|
||||||
|
10. `Дата создания` - когда пользователь создан
|
||||||
|
11. `Последняя активность` - последнее взаимодействие с ботом
|
||||||
|
12. `Заблокирован в чате` - статус блокировки в чате
|
||||||
|
|
||||||
|
**Преимущества XLSX:**
|
||||||
|
- ✅ Удобное редактирование в Excel/LibreOffice
|
||||||
|
- ✅ Визуальный контроль данных
|
||||||
|
- ✅ Авто-подбор ширины колонок
|
||||||
|
- ✅ Цветное оформление заголовков
|
||||||
|
- ✅ Легкая сортировка и фильтрация
|
||||||
|
|
||||||
|
**Безопасность:**
|
||||||
|
- 🔒 Статус админа НЕ импортируется из файла (только ручное назначение)
|
||||||
|
- 🔒 Все данные валидируются перед импортом
|
||||||
|
|
||||||
|
**Файлы:**
|
||||||
|
- `requirements.txt` - добавлена библиотека `openpyxl==3.1.2`
|
||||||
|
- `src/handlers/admin_panel.py` - обновлены функции экспорта/импорта
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 💬 Обработка команд выхода в чате
|
||||||
|
|
||||||
|
**Что добавлено:**
|
||||||
|
Теперь находясь в режиме чата можно быстро вернуться в главное меню, написав одну из команд:
|
||||||
|
- `/start` - выход из чата в главное меню
|
||||||
|
- `start` - выход из чата в главное меню
|
||||||
|
- `старт` - выход из чата в главное меню
|
||||||
|
- `/exit` - выход из чата (как и раньше)
|
||||||
|
|
||||||
|
**Как работает:**
|
||||||
|
1. Пользователь в режиме чата (ChatStates.in_chat)
|
||||||
|
2. Пишет одну из команд: `/start`, `start` или `старт`
|
||||||
|
3. Автоматически выходит из чата
|
||||||
|
4. Получает главное меню
|
||||||
|
|
||||||
|
**Преимущества:**
|
||||||
|
- ⚡ Быстрый возврат в меню без кнопок
|
||||||
|
- 🎯 Интуитивные команды (start/старт)
|
||||||
|
- 🔄 Совместимость с привычным поведением ботов
|
||||||
|
|
||||||
|
**Файлы:**
|
||||||
|
- `src/handlers/chat_handlers.py` - добавлена функция `check_exit_keywords`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. ❓ Система справки
|
||||||
|
|
||||||
|
**Что добавлено:**
|
||||||
|
Новая полноценная система помощи пользователям с интерактивной навигацией.
|
||||||
|
|
||||||
|
**Разделы справки:**
|
||||||
|
|
||||||
|
#### 📝 Регистрация
|
||||||
|
- Пошаговая инструкция по регистрации
|
||||||
|
- Какие данные нужны
|
||||||
|
- Процесс одобрения администратором
|
||||||
|
- Что дает регистрация
|
||||||
|
|
||||||
|
#### 🎰 Участие в розыгрышах
|
||||||
|
- Как принять участие в розыгрыше
|
||||||
|
- Что указано в описании розыгрыша
|
||||||
|
- Как узнать о результатах
|
||||||
|
- Что делать при выигрыше
|
||||||
|
|
||||||
|
#### 💬 Чат
|
||||||
|
- Вход и выход из чата
|
||||||
|
- Какие сообщения можно отправлять (текст, фото, видео, документы, стикеры)
|
||||||
|
- Правила чата
|
||||||
|
- Управление чатом для админов
|
||||||
|
|
||||||
|
#### ⚙️ Команды
|
||||||
|
- Список всех доступных команд бота
|
||||||
|
- Описание каждой команды
|
||||||
|
- Для админов - дополнительные админские команды
|
||||||
|
- Полезные советы по использованию
|
||||||
|
|
||||||
|
**Доступ к справке:**
|
||||||
|
- Кнопка `❓ Справка` в главном меню
|
||||||
|
- Команда `/help` в любой момент
|
||||||
|
- Интерактивная навигация между разделами
|
||||||
|
|
||||||
|
**Особенности:**
|
||||||
|
- 📱 Адаптивный контент (админы видят дополнительные команды)
|
||||||
|
- 🔄 Удобная навигация между разделами
|
||||||
|
- 🏠 Быстрый возврат в главное меню
|
||||||
|
- 📖 Подробные инструкции с примерами
|
||||||
|
|
||||||
|
**Файлы:**
|
||||||
|
- `src/handlers/help_handlers.py` - новый модуль справки (265 строк)
|
||||||
|
- `src/components/ui.py` - добавлена кнопка "❓ Справка" в главное меню
|
||||||
|
- `main.py` - зарегистрирован роутер справки
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Технические детали
|
||||||
|
|
||||||
|
### Зависимости
|
||||||
|
```txt
|
||||||
|
openpyxl==3.1.2 # Работа с Excel файлами
|
||||||
|
```
|
||||||
|
|
||||||
|
### Новые файлы
|
||||||
|
- `src/handlers/help_handlers.py` - система справки
|
||||||
|
|
||||||
|
### Обновленные файлы
|
||||||
|
- `requirements.txt` - добавлена openpyxl
|
||||||
|
- `main.py` - регистрация help_router
|
||||||
|
- `src/handlers/admin_panel.py` - XLSX экспорт/импорт
|
||||||
|
- `src/handlers/chat_handlers.py` - обработка ключевых слов
|
||||||
|
- `src/components/ui.py` - кнопка справки в меню
|
||||||
|
|
||||||
|
### Роутеры
|
||||||
|
```python
|
||||||
|
# Порядок регистрации роутеров:
|
||||||
|
1. main router (базовые команды)
|
||||||
|
2. message_admin_router
|
||||||
|
3. admin_router
|
||||||
|
4. registration_router
|
||||||
|
5. admin_account_router
|
||||||
|
6. admin_chat_router
|
||||||
|
7. redraw_router
|
||||||
|
8. p2p_chat_router
|
||||||
|
9. help_router # ← НОВЫЙ
|
||||||
|
10. chat_router
|
||||||
|
11. account_router
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Статистика изменений
|
||||||
|
|
||||||
|
- **Новые файлы:** 1 (help_handlers.py)
|
||||||
|
- **Измененные файлы:** 4 (admin_panel.py, chat_handlers.py, ui.py, main.py)
|
||||||
|
- **Новые зависимости:** 1 (openpyxl)
|
||||||
|
- **Новые команды:** 1 (/help)
|
||||||
|
- **Новые обработчики:** 6 (помощь + ключевые слова)
|
||||||
|
- **Строк кода добавлено:** ~400
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Тестирование
|
||||||
|
|
||||||
|
### Проверено:
|
||||||
|
- ✅ Бот успешно запускается
|
||||||
|
- ✅ Контейнер пересобран с новыми зависимостями
|
||||||
|
- ✅ Справка доступна из главного меню
|
||||||
|
- ✅ Кнопка "❓ Справка" работает (подтверждено логами)
|
||||||
|
- ✅ Обработчик help_main вызывается корректно
|
||||||
|
- ✅ Нет ошибок компиляции в новом коде
|
||||||
|
|
||||||
|
### Требуется протестировать:
|
||||||
|
- ⏳ Экспорт пользователей в XLSX
|
||||||
|
- ⏳ Импорт пользователей из XLSX
|
||||||
|
- ⏳ Обработка команд start/старт в чате
|
||||||
|
- ⏳ Навигация по всем разделам справки
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Итоги
|
||||||
|
|
||||||
|
Все три запрошенные функции успешно реализованы:
|
||||||
|
1. ✅ **XLSX экспорт/импорт** - удобная работа с данными пользователей
|
||||||
|
2. ✅ **Обработка start в чате** - быстрый возврат в главное меню
|
||||||
|
3. ✅ **Система справки** - полноценная помощь для пользователей
|
||||||
|
|
||||||
|
Бот готов к использованию новых функций! 🚀
|
||||||
470
docs/USER_MANAGEMENT_GUIDE.md
Normal file
470
docs/USER_MANAGEMENT_GUIDE.md
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
# Система управления пользователями
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
Система управления пользователями предоставляет администраторам инструменты для:
|
||||||
|
- Поиска пользователей по различным критериям
|
||||||
|
- Просмотра детальной информации о пользователях
|
||||||
|
- Блокировки/разблокировки пользователей в чате
|
||||||
|
- Управления большими базами пользователей (1000+)
|
||||||
|
|
||||||
|
## Функциональность
|
||||||
|
|
||||||
|
### 1. Блокировка в чате
|
||||||
|
|
||||||
|
**Назначение:** Запрет отправки сообщений в чат бота для конкретного пользователя.
|
||||||
|
|
||||||
|
**Как работает:**
|
||||||
|
- Заблокированный пользователь не может отправлять сообщения в P2P чат
|
||||||
|
- Блокировка не затрагивает другие функции бота (участие в розыгрышах, просмотр статистики)
|
||||||
|
- Блокировка фиксируется в поле `is_chat_banned` в базе данных
|
||||||
|
|
||||||
|
**Применение:**
|
||||||
|
1. Найти пользователя через поиск или список
|
||||||
|
2. Открыть карточку пользователя
|
||||||
|
3. Нажать кнопку "🚫 Заблокировать в чате"
|
||||||
|
|
||||||
|
### 2. Поиск пользователей
|
||||||
|
|
||||||
|
**Критерии поиска:**
|
||||||
|
- Username (с @ или без)
|
||||||
|
- Имя или фамилия
|
||||||
|
- Telegram ID
|
||||||
|
- Номер клубной карты
|
||||||
|
- Никнейм
|
||||||
|
|
||||||
|
**Пример запросов:**
|
||||||
|
- `@username` - поиск по username
|
||||||
|
- `Иван` - поиск по имени/фамилии
|
||||||
|
- `123456789` - поиск по Telegram ID или номеру карты
|
||||||
|
- `nickname` - поиск по никнейму
|
||||||
|
|
||||||
|
**Особенности:**
|
||||||
|
- Поиск работает по принципу "ИЛИ" (OR) - ищет во всех полях одновременно
|
||||||
|
- Поддержка частичного совпадения (ILIKE)
|
||||||
|
- Автоматическая пагинация (15 пользователей на странице)
|
||||||
|
|
||||||
|
### 3. Списки пользователей
|
||||||
|
|
||||||
|
#### Все пользователи
|
||||||
|
- Отображаются все зарегистрированные в боте
|
||||||
|
- Сортировка по дате создания (новые первые)
|
||||||
|
- Пагинация по 15 пользователей
|
||||||
|
|
||||||
|
#### Заблокированные пользователи
|
||||||
|
- Отображаются только пользователи с блокировкой в чате
|
||||||
|
- Быстрый доступ для управления блокировками
|
||||||
|
|
||||||
|
### 4. Карточка пользователя
|
||||||
|
|
||||||
|
**Отображаемая информация:**
|
||||||
|
- **Основное:**
|
||||||
|
- Имя и фамилия
|
||||||
|
- Username (если есть)
|
||||||
|
- Telegram ID
|
||||||
|
|
||||||
|
- **Статистика:**
|
||||||
|
- Дата регистрации
|
||||||
|
- Последняя активность
|
||||||
|
- Статус регистрации
|
||||||
|
- Статус администратора
|
||||||
|
|
||||||
|
- **Дополнительно:**
|
||||||
|
- Никнейм (если установлен)
|
||||||
|
- Номер клубной карты (если привязан)
|
||||||
|
- Телефон (если указан)
|
||||||
|
- Статус блокировки в чате
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
### Компоненты системы
|
||||||
|
|
||||||
|
#### 1. UserManagementService
|
||||||
|
**Файл:** `src/core/user_management.py`
|
||||||
|
|
||||||
|
**Методы:**
|
||||||
|
```python
|
||||||
|
# Поиск с фильтрами и пагинацией
|
||||||
|
async def search_users(
|
||||||
|
session: AsyncSession,
|
||||||
|
query: str = None, # Поисковый запрос
|
||||||
|
page: int = 1, # Номер страницы
|
||||||
|
per_page: int = 15, # Размер страницы
|
||||||
|
filters: Dict = None # Фильтры (is_registered, is_admin, is_chat_banned)
|
||||||
|
) -> Tuple[List[User], int]:
|
||||||
|
"""Возвращает (список пользователей, общее количество)"""
|
||||||
|
|
||||||
|
# Получение по ID
|
||||||
|
async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
|
||||||
|
"""Получить пользователя по внутреннему ID"""
|
||||||
|
|
||||||
|
# Блокировка в чате
|
||||||
|
async def ban_user_in_chat(session: AsyncSession, user_id: int) -> bool:
|
||||||
|
"""Заблокировать пользователя в чате"""
|
||||||
|
|
||||||
|
# Разблокировка в чате
|
||||||
|
async def unban_user_in_chat(session: AsyncSession, user_id: int) -> bool:
|
||||||
|
"""Разблокировать пользователя в чате"""
|
||||||
|
|
||||||
|
# Статистика
|
||||||
|
async def get_user_stats(session: AsyncSession) -> Dict[str, int]:
|
||||||
|
"""Получить статистику: total, registered, admins, chat_banned"""
|
||||||
|
|
||||||
|
# Форматирование
|
||||||
|
def format_user_info(user: User, detailed: bool = False) -> str:
|
||||||
|
"""Красивый HTML вывод информации о пользователе"""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Константы:**
|
||||||
|
- `USERS_PER_PAGE = 15` - количество пользователей на странице
|
||||||
|
|
||||||
|
#### 2. Обработчики админ-панели
|
||||||
|
**Файл:** `src/handlers/admin_panel.py`
|
||||||
|
|
||||||
|
**Основные обработчики:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Главное меню управления пользователями
|
||||||
|
@admin_router.callback_query(F.data == "admin_users")
|
||||||
|
async def admin_users_menu(callback: CallbackQuery):
|
||||||
|
"""Показывает статистику и главное меню"""
|
||||||
|
|
||||||
|
# Запрос поискового запроса
|
||||||
|
@admin_router.callback_query(F.data == "admin_users_search")
|
||||||
|
async def admin_users_search_prompt(callback: CallbackQuery, state: FSMContext):
|
||||||
|
"""Переводит в режим ожидания поискового запроса"""
|
||||||
|
|
||||||
|
# Обработка поиска
|
||||||
|
@admin_router.message(AdminStates.user_management_search)
|
||||||
|
async def admin_users_search_process(message: Message, state: FSMContext):
|
||||||
|
"""Выполняет поиск и показывает результаты"""
|
||||||
|
|
||||||
|
# Список всех пользователей
|
||||||
|
@admin_router.callback_query(F.data.startswith("admin_users_list:"))
|
||||||
|
async def admin_users_list(callback: CallbackQuery):
|
||||||
|
"""Постраничный список всех пользователей"""
|
||||||
|
|
||||||
|
# Список заблокированных
|
||||||
|
@admin_router.callback_query(F.data.startswith("admin_users_banned:"))
|
||||||
|
async def admin_users_banned_list(callback: CallbackQuery):
|
||||||
|
"""Список только заблокированных пользователей"""
|
||||||
|
|
||||||
|
# Просмотр пользователя
|
||||||
|
@admin_router.callback_query(F.data.startswith("admin_user_view:"))
|
||||||
|
async def admin_user_view(callback: CallbackQuery):
|
||||||
|
"""Детальная карточка пользователя"""
|
||||||
|
|
||||||
|
# Блокировка
|
||||||
|
@admin_router.callback_query(F.data.startswith("admin_user_ban:"))
|
||||||
|
async def admin_user_ban(callback: CallbackQuery):
|
||||||
|
"""Заблокировать пользователя"""
|
||||||
|
|
||||||
|
# Разблокировка
|
||||||
|
@admin_router.callback_query(F.data.startswith("admin_user_unban:"))
|
||||||
|
async def admin_user_unban(callback: CallbackQuery):
|
||||||
|
"""Разблокировать пользователя"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. FSM состояния
|
||||||
|
**Файл:** `src/handlers/admin_panel.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
class AdminStates(StatesGroup):
|
||||||
|
# ... другие состояния ...
|
||||||
|
user_management_search = State() # Ожидание поискового запроса
|
||||||
|
user_management_view = State() # Просмотр пользователя
|
||||||
|
```
|
||||||
|
|
||||||
|
### Модель данных
|
||||||
|
|
||||||
|
#### Поле is_chat_banned
|
||||||
|
**Файл:** `src/core/models.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
class User(Base):
|
||||||
|
# ... другие поля ...
|
||||||
|
is_chat_banned: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
last_activity: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Миграция:** `migrations/versions/20260215_0403_00_b4c435a7dc5f_add_is_chat_banned_to_users.py`
|
||||||
|
|
||||||
|
### Интеграция с чатом
|
||||||
|
|
||||||
|
**Файл:** `src/core/chat_services.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ChatPermissionService:
|
||||||
|
@staticmethod
|
||||||
|
async def can_send_message(session: AsyncSession, telegram_id: int) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Проверяет, может ли пользователь отправлять сообщения в чат
|
||||||
|
|
||||||
|
Порядок проверок:
|
||||||
|
1. Глобальная блокировка (BanService)
|
||||||
|
2. Блокировка в чате (is_chat_banned)
|
||||||
|
3. Регистрация в боте
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(может_отправить: bool, причина_если_нет: str)
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Проверка в обработчиках P2P сообщений:**
|
||||||
|
```python
|
||||||
|
can_send, reason = await ChatPermissionService.can_send_message(session, message.from_user.id)
|
||||||
|
if not can_send:
|
||||||
|
await message.answer(f"❌ {reason}")
|
||||||
|
return
|
||||||
|
```
|
||||||
|
|
||||||
|
## Производительность
|
||||||
|
|
||||||
|
### Оптимизация для больших баз (1000+ пользователей)
|
||||||
|
|
||||||
|
#### 1. Пагинация
|
||||||
|
- Все списки ограничены 15 пользователями на странице
|
||||||
|
- SQL LIMIT/OFFSET для эффективных запросов
|
||||||
|
- Подсчет общего количества отдельным запросом
|
||||||
|
|
||||||
|
#### 2. Индексы (рекомендуется добавить)
|
||||||
|
```sql
|
||||||
|
-- Для поиска по username
|
||||||
|
CREATE INDEX idx_users_username ON users(username);
|
||||||
|
|
||||||
|
-- Для поиска по имени/фамилии
|
||||||
|
CREATE INDEX idx_users_names ON users(first_name, last_name);
|
||||||
|
|
||||||
|
-- Для поиска по клубной карте
|
||||||
|
CREATE INDEX idx_users_card ON users(club_card_number);
|
||||||
|
|
||||||
|
-- Для фильтрации заблокированных
|
||||||
|
CREATE INDEX idx_users_chat_banned ON users(is_chat_banned) WHERE is_chat_banned = true;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. ILIKE оптимизация
|
||||||
|
Для больших баз рекомендуется добавить индекс с расширением pg_trgm:
|
||||||
|
```sql
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||||
|
CREATE INDEX idx_users_username_trgm ON users USING gin(username gin_trgm_ops);
|
||||||
|
CREATE INDEX idx_users_names_trgm ON users USING gin((first_name || ' ' || last_name) gin_trgm_ops);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Кеширование (будущее улучшение)
|
||||||
|
- Кеш для статистики (Redis)
|
||||||
|
- Кеш для часто просматриваемых пользователей
|
||||||
|
- TTL: 5 минут
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Для администраторов
|
||||||
|
|
||||||
|
#### Шаг 1: Доступ к управлению
|
||||||
|
1. Открыть админ-панель: `/admin`
|
||||||
|
2. Выбрать "👤 Управление пользователями"
|
||||||
|
|
||||||
|
#### Шаг 2: Поиск пользователя
|
||||||
|
**Вариант A: Быстрый поиск**
|
||||||
|
1. Нажать "🔍 Поиск пользователей"
|
||||||
|
2. Ввести запрос (username, имя, ID)
|
||||||
|
3. Выбрать пользователя из результатов
|
||||||
|
|
||||||
|
**Вариант B: Просмотр списка**
|
||||||
|
1. Нажать "📋 Все пользователи"
|
||||||
|
2. Перемещаться по страницам (⬅️ Назад / ➡️ Далее)
|
||||||
|
3. Выбрать пользователя
|
||||||
|
|
||||||
|
**Вариант C: Только заблокированные**
|
||||||
|
1. Нажать "🚫 Заблокированные"
|
||||||
|
2. Просмотреть список заблокированных
|
||||||
|
3. Выбрать для разблокировки
|
||||||
|
|
||||||
|
#### Шаг 3: Управление блокировкой
|
||||||
|
1. В карточке пользователя нажать:
|
||||||
|
- "🚫 Заблокировать в чате" - для блокировки
|
||||||
|
- "✅ Разблокировать в чате" - для разблокировки
|
||||||
|
2. Подтвердить действие
|
||||||
|
|
||||||
|
### Для разработчиков
|
||||||
|
|
||||||
|
#### Добавление новых фильтров
|
||||||
|
|
||||||
|
```python
|
||||||
|
# В UserManagementService.search_users()
|
||||||
|
if filters:
|
||||||
|
if 'custom_field' in filters:
|
||||||
|
conditions.append(User.custom_field == filters['custom_field'])
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Добавление новых полей в карточку
|
||||||
|
|
||||||
|
```python
|
||||||
|
# В UserManagementService.format_user_info()
|
||||||
|
if detailed:
|
||||||
|
text += f"🆕 Кастомное поле: {user.custom_field}\n"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Создание нового списка
|
||||||
|
|
||||||
|
```python
|
||||||
|
@admin_router.callback_query(F.data.startswith("admin_users_custom:"))
|
||||||
|
async def admin_users_custom_list(callback: CallbackQuery):
|
||||||
|
page = int(callback.data.split(":")[1])
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
users, total = await UserManagementService.search_users(
|
||||||
|
session,
|
||||||
|
page=page,
|
||||||
|
filters={'custom_field': True}
|
||||||
|
)
|
||||||
|
|
||||||
|
# ... рендер списка ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Мониторинг
|
||||||
|
|
||||||
|
### Логирование
|
||||||
|
```python
|
||||||
|
logger.info(f"Пользователь {user.telegram_id} заблокирован в чате")
|
||||||
|
logger.error(f"Ошибка блокировки пользователя {user_id}: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Метрики для мониторинга
|
||||||
|
- Количество поисковых запросов
|
||||||
|
- Среднее время ответа на поиск
|
||||||
|
- Количество блокировок/разблокировок
|
||||||
|
- Топ-10 самых активных поисковых запросов
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
### Проверка прав доступа
|
||||||
|
Все обработчики проверяют права администратора:
|
||||||
|
```python
|
||||||
|
if not is_admin(callback.from_user.id):
|
||||||
|
await callback.answer("❌ Доступ запрещен", show_alert=True)
|
||||||
|
return
|
||||||
|
```
|
||||||
|
|
||||||
|
### Защита от SQL-инъекций
|
||||||
|
- Все запросы используют параметризованные запросы SQLAlchemy
|
||||||
|
- Нет прямой конкатенации SQL
|
||||||
|
|
||||||
|
### Аудит действий (рекомендуется добавить)
|
||||||
|
```sql
|
||||||
|
CREATE TABLE admin_actions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
admin_telegram_id BIGINT NOT NULL,
|
||||||
|
action VARCHAR(50) NOT NULL, -- 'ban_user', 'unban_user', 'search_user'
|
||||||
|
target_user_id INTEGER,
|
||||||
|
details JSONB,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Расширения
|
||||||
|
|
||||||
|
### Планируемые улучшения
|
||||||
|
|
||||||
|
1. **Массовые операции**
|
||||||
|
- Блокировка нескольких пользователей одновременно
|
||||||
|
- Экспорт списка в CSV/JSON
|
||||||
|
|
||||||
|
2. **Расширенные фильтры**
|
||||||
|
- Диапазон дат регистрации
|
||||||
|
- Неактивные пользователи (по last_activity)
|
||||||
|
- Пользователи без клубной карты
|
||||||
|
|
||||||
|
3. **История блокировок**
|
||||||
|
- Журнал всех блокировок/разблокировок
|
||||||
|
- Кто и когда заблокировал
|
||||||
|
- Причина блокировки (опциональное поле)
|
||||||
|
|
||||||
|
4. **Автоматическая блокировка**
|
||||||
|
- При определенном количестве жалоб от других пользователей
|
||||||
|
- При обнаружении спам-активности
|
||||||
|
|
||||||
|
5. **Уведомления**
|
||||||
|
- Уведомление пользователя о блокировке (опционально)
|
||||||
|
- Уведомление администраторов о подозрительной активности
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Проблема: Поиск не находит пользователя
|
||||||
|
**Решение:**
|
||||||
|
- Убедиться, что пользователь существует в базе
|
||||||
|
- Проверить правильность написания (регистр не важен)
|
||||||
|
- Попробовать поиск по Telegram ID
|
||||||
|
|
||||||
|
### Проблема: Блокировка не применяется
|
||||||
|
**Решение:**
|
||||||
|
- Проверить логи: `docker logs lottery_bot | grep "блокирован"`
|
||||||
|
- Убедиться, что транзакция закоммитилась
|
||||||
|
- Проверить поле в БД: `SELECT telegram_id, is_chat_banned FROM users WHERE id = ?`
|
||||||
|
|
||||||
|
### Проблема: Медленный поиск (>2 секунд)
|
||||||
|
**Решение:**
|
||||||
|
- Добавить индексы (см. раздел "Производительность")
|
||||||
|
- Проверить EXPLAIN ANALYZE для поискового запроса
|
||||||
|
- Рассмотреть использование полнотекстового поиска PostgreSQL
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
### Ручное тестирование
|
||||||
|
|
||||||
|
1. **Тест поиска:**
|
||||||
|
```
|
||||||
|
- Поиск по существующему username
|
||||||
|
- Поиск по несуществующему username
|
||||||
|
- Поиск с частичным совпадением
|
||||||
|
- Поиск по Telegram ID
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Тест блокировки:**
|
||||||
|
```
|
||||||
|
- Заблокировать пользователя
|
||||||
|
- Попытаться отправить сообщение от заблокированного пользователя
|
||||||
|
- Разблокировать пользователя
|
||||||
|
- Убедиться, что сообщения проходят
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Тест пагинации:**
|
||||||
|
```
|
||||||
|
- Создать >15 пользователей
|
||||||
|
- Проверить навигацию вперед/назад
|
||||||
|
- Проверить корректность подсчета страниц
|
||||||
|
```
|
||||||
|
|
||||||
|
### Автоматическое тестирование
|
||||||
|
|
||||||
|
```python
|
||||||
|
# tests/test_user_management.py
|
||||||
|
import pytest
|
||||||
|
from src.core.user_management import UserManagementService
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_users_by_username(session):
|
||||||
|
users, total = await UserManagementService.search_users(
|
||||||
|
session, query="@testuser"
|
||||||
|
)
|
||||||
|
assert len(users) > 0
|
||||||
|
assert users[0].username == "testuser"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ban_user(session):
|
||||||
|
success = await UserManagementService.ban_user_in_chat(session, 1)
|
||||||
|
assert success == True
|
||||||
|
|
||||||
|
user = await UserManagementService.get_user_by_id(session, 1)
|
||||||
|
assert user.is_chat_banned == True
|
||||||
|
```
|
||||||
|
|
||||||
|
## Заключение
|
||||||
|
|
||||||
|
Система управления пользователями предоставляет:
|
||||||
|
- ✅ Быстрый и удобный поиск среди больших баз данных
|
||||||
|
- ✅ Простое управление блокировками в чате
|
||||||
|
- ✅ Масштабируемость для 1000+ пользователей
|
||||||
|
- ✅ Интуитивный интерфейс для администраторов
|
||||||
|
- ✅ Интеграцию с системой разрешений чата
|
||||||
|
|
||||||
|
Система готова к использованию и может быть расширена дополнительными функциями по мере необходимости.
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"export_date": "2026-02-08T17:40:31.898764",
|
|
||||||
"statistics": {
|
|
||||||
"users": 3,
|
|
||||||
"lotteries": 1,
|
|
||||||
"participations": 1,
|
|
||||||
"winners": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"export_date": "2026-02-08T17:42:08.014799",
|
|
||||||
"statistics": {
|
|
||||||
"users": 3,
|
|
||||||
"lotteries": 1,
|
|
||||||
"participations": 1,
|
|
||||||
"winners": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"export_date": "2026-02-08T17:42:21.844218",
|
|
||||||
"statistics": {
|
|
||||||
"users": 3,
|
|
||||||
"lotteries": 1,
|
|
||||||
"participations": 1,
|
|
||||||
"winners": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
188
main.py
188
main.py
@@ -10,11 +10,16 @@ from aiogram import Bot, Dispatcher, Router, F
|
|||||||
from aiogram.types import Message, CallbackQuery
|
from aiogram.types import Message, CallbackQuery
|
||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
from aiogram.fsm.storage.memory import MemoryStorage
|
from aiogram.fsm.storage.memory import MemoryStorage
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
|
||||||
|
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||||
|
|
||||||
from src.core.config import BOT_TOKEN
|
from src.core.config import BOT_TOKEN
|
||||||
from src.core.database import async_session_maker
|
from src.core.database import async_session_maker
|
||||||
|
from src.core.scheduler import bot_scheduler
|
||||||
from src.container import container
|
from src.container import container
|
||||||
from src.interfaces.base import IBotController
|
from src.interfaces.base import IBotController
|
||||||
|
from src.middlewares.activity import ActivityMiddleware
|
||||||
from src.handlers.admin_panel import admin_router
|
from src.handlers.admin_panel import admin_router
|
||||||
from src.handlers.registration_handlers import router as registration_router
|
from src.handlers.registration_handlers import router as registration_router
|
||||||
from src.handlers.admin_account_handlers import router as admin_account_router
|
from src.handlers.admin_account_handlers import router as admin_account_router
|
||||||
@@ -24,6 +29,8 @@ from src.handlers.admin_chat_handlers import router as admin_chat_router
|
|||||||
from src.handlers.account_handlers import account_router
|
from src.handlers.account_handlers import account_router
|
||||||
from src.handlers.message_management import message_admin_router
|
from src.handlers.message_management import message_admin_router
|
||||||
from src.handlers.p2p_chat import router as p2p_chat_router
|
from src.handlers.p2p_chat import router as p2p_chat_router
|
||||||
|
from src.handlers.help_handlers import router as help_router
|
||||||
|
from src.handlers.admin_emoji_handlers import router as admin_emoji_router
|
||||||
|
|
||||||
# Настройка логирования
|
# Настройка логирования
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -60,18 +67,175 @@ async def get_controller():
|
|||||||
|
|
||||||
# === COMMAND HANDLERS ===
|
# === COMMAND HANDLERS ===
|
||||||
|
|
||||||
@router.message(Command("start"))
|
@router.message(CaseInsensitiveCommand("start"))
|
||||||
async def cmd_start(message: Message):
|
async def cmd_start(message: Message):
|
||||||
"""Обработчик команды /start"""
|
"""Обработчик команды /start (регистронезависимо)"""
|
||||||
async with get_controller() as controller:
|
async with get_controller() as controller:
|
||||||
await controller.handle_start(message)
|
await controller.handle_start(message)
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("admin"))
|
# === TEXT BUTTON HANDLERS ===
|
||||||
async def cmd_admin(message: Message):
|
|
||||||
"""Обработчик команды /admin - перенаправляет в admin_panel"""
|
@router.message(F.text == "🎰 Розыгрыши")
|
||||||
|
async def btn_lotteries(message: Message):
|
||||||
|
"""Обработчик кнопки 'Розыгрыши'"""
|
||||||
|
from src.core.database import async_session_maker
|
||||||
|
from src.repositories.implementations import LotteryRepository, ParticipationRepository
|
||||||
|
from src.display.message_formatter import MessageFormatterImpl
|
||||||
|
from src.components.ui import KeyboardBuilderImpl
|
||||||
|
from src.core.services import UserService
|
||||||
from src.core.config import ADMIN_IDS
|
from src.core.config import ADMIN_IDS
|
||||||
if message.from_user.id not in ADMIN_IDS:
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
lottery_repo = LotteryRepository(session)
|
||||||
|
participation_repo = ParticipationRepository(session)
|
||||||
|
lotteries = await lottery_repo.get_active()
|
||||||
|
|
||||||
|
if not lotteries:
|
||||||
|
await message.answer("❌ Нет активных розыгрышей")
|
||||||
|
return
|
||||||
|
|
||||||
|
text = "🎲 **Активные розыгрыши:**\n\n"
|
||||||
|
formatter = MessageFormatterImpl()
|
||||||
|
|
||||||
|
for lottery in lotteries:
|
||||||
|
participants_count = await participation_repo.get_count_by_lottery(lottery.id)
|
||||||
|
lottery_info = formatter.format_lottery_info(lottery, participants_count)
|
||||||
|
text += lottery_info + "\n" + "="*30 + "\n\n"
|
||||||
|
|
||||||
|
# Получаем информацию о регистрации пользователя
|
||||||
|
user_service = UserService(session)
|
||||||
|
user = await user_service.get_or_create_user(
|
||||||
|
telegram_id=message.from_user.id,
|
||||||
|
username=message.from_user.username,
|
||||||
|
first_name=message.from_user.first_name,
|
||||||
|
last_name=message.from_user.last_name
|
||||||
|
)
|
||||||
|
|
||||||
|
keyboard_builder = KeyboardBuilderImpl()
|
||||||
|
keyboard = keyboard_builder.get_main_keyboard(
|
||||||
|
is_admin=message.from_user.id in ADMIN_IDS,
|
||||||
|
is_registered=user.is_registered
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
text,
|
||||||
|
reply_markup=keyboard,
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.text == "💬 Чат")
|
||||||
|
async def btn_chat(message: Message, state: FSMContext):
|
||||||
|
"""Обработчик кнопки 'Чат'"""
|
||||||
|
from src.handlers.chat_handlers import enter_chat
|
||||||
|
await enter_chat(message, state)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.text == "📝 Регистрация")
|
||||||
|
async def btn_registration(message: Message, state: FSMContext):
|
||||||
|
"""Обработчик кнопки 'Регистрация'"""
|
||||||
|
from src.handlers.registration_handlers import RegistrationStates
|
||||||
|
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
|
||||||
|
logger.info(f"User {message.from_user.id} pressed Registration button")
|
||||||
|
|
||||||
|
text = (
|
||||||
|
"📝 Регистрация в системе\n\n"
|
||||||
|
"Для участия в розыгрышах необходимо зарегистрироваться.\n\n"
|
||||||
|
"Шаг 1 из 3: Придумайте никнейм\n\n"
|
||||||
|
"🎭 Введите ваш никнейм для чата:\n"
|
||||||
|
"• От 2 до 20 символов\n"
|
||||||
|
"• Может содержать буквы, цифры, пробелы\n"
|
||||||
|
"• Это имя будут видеть другие участники"
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
text,
|
||||||
|
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||||
|
[InlineKeyboardButton(text="❌ Отмена", callback_data="back_to_main")]
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
|
await state.set_state(RegistrationStates.waiting_for_nickname)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(CaseInsensitiveCommand("register"))
|
||||||
|
async def cmd_register(message: Message, state: FSMContext):
|
||||||
|
"""Обработчик команды /register (регистронезависимо)"""
|
||||||
|
await btn_registration(message, state)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.text.lower().in_(["регистрация", "регистр", "register"]))
|
||||||
|
async def text_registration(message: Message, state: FSMContext):
|
||||||
|
"""Обработчик текста для регистрации"""
|
||||||
|
await btn_registration(message, state)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.text == "🔑 Мой код")
|
||||||
|
async def btn_my_code(message: Message):
|
||||||
|
"""Обработчик кнопки 'Мой код'"""
|
||||||
|
from src.handlers.registration_handlers import show_verification_code
|
||||||
|
await show_verification_code(message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.text == "<EFBFBD> Мои логины")
|
||||||
|
async def btn_my_accounts(message: Message):
|
||||||
|
"""Обработчик кнопки 'Мои логины'"""
|
||||||
|
from src.handlers.registration_handlers import show_user_accounts
|
||||||
|
await show_user_accounts(message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.text == "❓ Справка")
|
||||||
|
async def btn_help(message: Message):
|
||||||
|
"""Обработчик кнопки 'Справка'"""
|
||||||
|
from src.handlers.help_handlers import show_help_main
|
||||||
|
await show_help_main(message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.text == "⚙️ Админ панель")
|
||||||
|
async def btn_admin(message: Message):
|
||||||
|
"""Обработчик кнопки 'Админ панель'"""
|
||||||
|
await cmd_admin(message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.text == "🚪 Выйти из чата")
|
||||||
|
async def btn_exit_chat(message: Message, state: FSMContext):
|
||||||
|
"""Обработчик кнопки 'Выйти из чата'"""
|
||||||
|
from src.handlers.chat_handlers import exit_chat
|
||||||
|
await exit_chat(message, state)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.text == "🏠 Главная")
|
||||||
|
async def btn_main_menu(message: Message):
|
||||||
|
"""Обработчик кнопки 'Главная'"""
|
||||||
|
await cmd_start(message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(CaseInsensitiveCommand("admin"))
|
||||||
|
async def cmd_admin(message: Message):
|
||||||
|
"""Обработчик команды /admin (регистронезависимо) - перенаправляет в admin_panel"""
|
||||||
|
from src.core.config import ADMIN_IDS
|
||||||
|
from src.core.database import async_session_maker
|
||||||
|
from src.core.models import User
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
# Проверяем, является ли пользователь главным администратором из .env
|
||||||
|
user_id = message.from_user.id
|
||||||
|
is_super_admin = user_id in ADMIN_IDS
|
||||||
|
|
||||||
|
# Проверяем, является ли пользователь назначенным администратором
|
||||||
|
is_assigned_admin = False
|
||||||
|
if not is_super_admin:
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
user = await session.execute(
|
||||||
|
select(User).where(User.telegram_id == user_id)
|
||||||
|
)
|
||||||
|
user = user.scalar_one_or_none()
|
||||||
|
is_assigned_admin = user and user.is_admin
|
||||||
|
|
||||||
|
# Если не администратор ни того, ни другого типа
|
||||||
|
if not (is_super_admin or is_assigned_admin):
|
||||||
await message.answer("❌ Недостаточно прав для доступа к админ панели")
|
await message.answer("❌ Недостаточно прав для доступа к админ панели")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -116,18 +280,24 @@ async def main():
|
|||||||
"""Главная функция запуска бота"""
|
"""Главная функция запуска бота"""
|
||||||
logger.info("Запуск бота...")
|
logger.info("Запуск бота...")
|
||||||
|
|
||||||
|
# Подключаем middleware для отслеживания активности
|
||||||
|
dp.message.middleware(ActivityMiddleware())
|
||||||
|
dp.callback_query.middleware(ActivityMiddleware())
|
||||||
|
|
||||||
# Подключаем роутеры в правильном порядке
|
# Подключаем роутеры в правильном порядке
|
||||||
# 1. Основной роутер main.py с базовыми командами (/start, /help, /admin)
|
# 1. Основной роутер main.py с базовыми командами (/start, /help, /admin)
|
||||||
dp.include_router(router)
|
dp.include_router(router)
|
||||||
|
|
||||||
# 2. Специфичные роутеры
|
# 2. Специфичные роутеры
|
||||||
dp.include_router(message_admin_router) # Управление сообщениями администратором
|
dp.include_router(message_admin_router) # Управление сообщениями администратором
|
||||||
|
dp.include_router(admin_emoji_router) # Управление кастомными эмодзи
|
||||||
dp.include_router(admin_router) # Админ панель - самая высокая специфичность
|
dp.include_router(admin_router) # Админ панель - самая высокая специфичность
|
||||||
dp.include_router(registration_router) # Регистрация
|
dp.include_router(registration_router) # Регистрация
|
||||||
dp.include_router(admin_account_router) # Админские команды счетов
|
dp.include_router(admin_account_router) # Админские команды счетов
|
||||||
dp.include_router(admin_chat_router) # Админские команды чата
|
dp.include_router(admin_chat_router) # Админские команды чата
|
||||||
dp.include_router(redraw_router) # Повторные розыгрыши
|
dp.include_router(redraw_router) # Повторные розыгрыши
|
||||||
dp.include_router(p2p_chat_router) # P2P чат между пользователями
|
dp.include_router(p2p_chat_router) # P2P чат между пользователями
|
||||||
|
dp.include_router(help_router) # Справка и помощь
|
||||||
|
|
||||||
# 3. Chat router для broadcast (обрабатывает обычные сообщения)
|
# 3. Chat router для broadcast (обрабатывает обычные сообщения)
|
||||||
dp.include_router(chat_router) # Пользовательский чат (broadcast всем) - РАНЬШЕ account_router
|
dp.include_router(chat_router) # Пользовательский чат (broadcast всем) - РАНЬШЕ account_router
|
||||||
@@ -135,6 +305,10 @@ async def main():
|
|||||||
# 4. Account router для обнаружения счетов (обрабатывает сообщения со счетами от админов)
|
# 4. Account router для обнаружения счетов (обрабатывает сообщения со счетами от админов)
|
||||||
dp.include_router(account_router) # Обнаружение счетов для админов - ПОСЛЕ chat_router
|
dp.include_router(account_router) # Обнаружение счетов для админов - ПОСЛЕ chat_router
|
||||||
|
|
||||||
|
# Запускаем планировщик задач
|
||||||
|
bot_scheduler.start()
|
||||||
|
logger.info("Планировщик задач запущен")
|
||||||
|
|
||||||
# Запускаем polling
|
# Запускаем polling
|
||||||
try:
|
try:
|
||||||
logger.info("Бот запущен")
|
logger.info("Бот запущен")
|
||||||
@@ -142,6 +316,8 @@ async def main():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при запуске бота: {e}")
|
logger.error(f"Ошибка при запуске бота: {e}")
|
||||||
finally:
|
finally:
|
||||||
|
# Останавливаем планировщик
|
||||||
|
bot_scheduler.shutdown()
|
||||||
await bot.session.close()
|
await bot.session.close()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""add_nickname_to_users
|
||||||
|
|
||||||
|
Revision ID: 64c4f8a81afa
|
||||||
|
Revises: beb47ddbfc33
|
||||||
|
Create Date: 2026-02-09 20:10:36.120201
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '64c4f8a81afa'
|
||||||
|
down_revision = 'beb47ddbfc33'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Добавляем поле nickname в таблицу users
|
||||||
|
op.add_column('users', sa.Column('nickname', sa.String(length=100), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Удаляем поле nickname из таблицы users
|
||||||
|
op.drop_column('users', 'nickname')
|
||||||
28
migrations/versions/20260213_1812_12_41aae82e631b_.py
Normal file
28
migrations/versions/20260213_1812_12_41aae82e631b_.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""
|
||||||
|
|
||||||
|
Revision ID: 41aae82e631b
|
||||||
|
Revises: 64c4f8a81afa
|
||||||
|
Create Date: 2026-02-13 18:12:12.031589
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '41aae82e631b'
|
||||||
|
down_revision = '64c4f8a81afa'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""add_is_chat_banned_to_users
|
||||||
|
|
||||||
|
Revision ID: b4c435a7dc5f
|
||||||
|
Revises: 1f1631301809
|
||||||
|
Create Date: 2026-02-15 04:03:00.221540
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'b4c435a7dc5f'
|
||||||
|
down_revision = '1f1631301809'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Добавление колонки is_chat_banned в таблицу users
|
||||||
|
op.add_column('users', sa.Column('is_chat_banned', sa.Boolean(), nullable=False, server_default='false'))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Удаление колонки is_chat_banned из таблицы users
|
||||||
|
op.drop_column('users', 'is_chat_banned')
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""add_broadcast_system_tables
|
||||||
|
|
||||||
|
Revision ID: d19b1c0718df
|
||||||
|
Revises: 64c4f8a81afa
|
||||||
|
Create Date: 2026-02-15 10:33:39.894994
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'd19b1c0718df'
|
||||||
|
down_revision = '64c4f8a81afa'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""add_broadcast_system_tables
|
||||||
|
|
||||||
|
Revision ID: 71376bb89294
|
||||||
|
Revises: d19b1c0718df
|
||||||
|
Create Date: 2026-02-15 10:33:55.664377
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '71376bb89294'
|
||||||
|
down_revision = 'd19b1c0718df'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Создание таблицы broadcast_channels
|
||||||
|
op.create_table(
|
||||||
|
'broadcast_channels',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('chat_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('chat_type', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('username', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('added_by', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['added_by'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_broadcast_channels_chat_id'), 'broadcast_channels', ['chat_id'], unique=True)
|
||||||
|
op.create_index(op.f('ix_broadcast_channels_is_active'), 'broadcast_channels', ['is_active'], unique=False)
|
||||||
|
|
||||||
|
# Создание таблицы blocked_users
|
||||||
|
op.create_table(
|
||||||
|
'blocked_users',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('telegram_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('error_type', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('error_message', sa.Text(), nullable=True),
|
||||||
|
sa.Column('first_blocked_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('last_attempt_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('attempt_count', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_blocked_users_telegram_id'), 'blocked_users', ['telegram_id'], unique=True)
|
||||||
|
op.create_index(op.f('ix_blocked_users_is_active'), 'blocked_users', ['is_active'], unique=False)
|
||||||
|
|
||||||
|
# Создание таблицы broadcast_logs
|
||||||
|
op.create_table(
|
||||||
|
'broadcast_logs',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('broadcast_type', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('target_id', sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column('message_type', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('message_text', sa.Text(), nullable=True),
|
||||||
|
sa.Column('file_id', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('total_recipients', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('success_count', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('failed_count', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('blocked_count', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('created_by', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=20), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_broadcast_logs_broadcast_type'), 'broadcast_logs', ['broadcast_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_broadcast_logs_status'), 'broadcast_logs', ['status'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Удаление таблиц в обратном порядке
|
||||||
|
op.drop_index(op.f('ix_broadcast_logs_status'), table_name='broadcast_logs')
|
||||||
|
op.drop_index(op.f('ix_broadcast_logs_broadcast_type'), table_name='broadcast_logs')
|
||||||
|
op.drop_table('broadcast_logs')
|
||||||
|
|
||||||
|
op.drop_index(op.f('ix_blocked_users_is_active'), table_name='blocked_users')
|
||||||
|
op.drop_index(op.f('ix_blocked_users_telegram_id'), table_name='blocked_users')
|
||||||
|
op.drop_table('blocked_users')
|
||||||
|
|
||||||
|
op.drop_index(op.f('ix_broadcast_channels_is_active'), table_name='broadcast_channels')
|
||||||
|
op.drop_index(op.f('ix_broadcast_channels_chat_id'), table_name='broadcast_channels')
|
||||||
|
op.drop_table('broadcast_channels')
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""add_last_activity_to_users
|
||||||
|
|
||||||
|
Revision ID: 1f1631301809
|
||||||
|
Revises: 71376bb89294
|
||||||
|
Create Date: 2026-02-15 12:01:08.471873
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '1f1631301809'
|
||||||
|
down_revision = '71376bb89294'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Добавляем поле last_activity
|
||||||
|
op.add_column('users', sa.Column('last_activity', sa.DateTime(timezone=True), nullable=True))
|
||||||
|
|
||||||
|
# Заполняем существующие записи значением created_at
|
||||||
|
op.execute('UPDATE users SET last_activity = created_at WHERE last_activity IS NULL')
|
||||||
|
|
||||||
|
# Делаем поле NOT NULL после заполнения
|
||||||
|
op.alter_column('users', 'last_activity', nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('users', 'last_activity')
|
||||||
88
migrations/versions/20260217_0032_55_12efff9b8e0c_.py
Normal file
88
migrations/versions/20260217_0032_55_12efff9b8e0c_.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""
|
||||||
|
|
||||||
|
Revision ID: 12efff9b8e0c
|
||||||
|
Revises: b4c435a7dc5f
|
||||||
|
Create Date: 2026-02-17 00:32:55.244678
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '12efff9b8e0c'
|
||||||
|
down_revision = 'b4c435a7dc5f'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('blocked_users',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('telegram_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('error_type', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('error_message', sa.Text(), nullable=True),
|
||||||
|
sa.Column('first_blocked_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('last_attempt_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('attempt_count', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_blocked_users_is_active'), 'blocked_users', ['is_active'], unique=False)
|
||||||
|
op.create_index(op.f('ix_blocked_users_telegram_id'), 'blocked_users', ['telegram_id'], unique=True)
|
||||||
|
op.create_table('broadcast_channels',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('chat_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('chat_type', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('username', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('added_by', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['added_by'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_broadcast_channels_chat_id'), 'broadcast_channels', ['chat_id'], unique=True)
|
||||||
|
op.create_index(op.f('ix_broadcast_channels_is_active'), 'broadcast_channels', ['is_active'], unique=False)
|
||||||
|
op.create_table('broadcast_logs',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('broadcast_type', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('target_id', sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column('message_type', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('message_text', sa.Text(), nullable=True),
|
||||||
|
sa.Column('file_id', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('total_recipients', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('success_count', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('failed_count', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('blocked_count', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('created_by', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=20), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_broadcast_logs_broadcast_type'), 'broadcast_logs', ['broadcast_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_broadcast_logs_status'), 'broadcast_logs', ['status'], unique=False)
|
||||||
|
op.add_column('users', sa.Column('is_chat_banned', sa.Boolean(), nullable=True))
|
||||||
|
op.add_column('users', sa.Column('last_activity', sa.DateTime(timezone=True), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('users', 'last_activity')
|
||||||
|
op.drop_column('users', 'is_chat_banned')
|
||||||
|
op.drop_index(op.f('ix_broadcast_logs_status'), table_name='broadcast_logs')
|
||||||
|
op.drop_index(op.f('ix_broadcast_logs_broadcast_type'), table_name='broadcast_logs')
|
||||||
|
op.drop_table('broadcast_logs')
|
||||||
|
op.drop_index(op.f('ix_broadcast_channels_is_active'), table_name='broadcast_channels')
|
||||||
|
op.drop_index(op.f('ix_broadcast_channels_chat_id'), table_name='broadcast_channels')
|
||||||
|
op.drop_table('broadcast_channels')
|
||||||
|
op.drop_index(op.f('ix_blocked_users_telegram_id'), table_name='blocked_users')
|
||||||
|
op.drop_index(op.f('ix_blocked_users_is_active'), table_name='blocked_users')
|
||||||
|
op.drop_table('blocked_users')
|
||||||
|
# ### end Alembic commands ###
|
||||||
28
migrations/versions/20260217_0034_52_cd31303a681c_.py
Normal file
28
migrations/versions/20260217_0034_52_cd31303a681c_.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""
|
||||||
|
|
||||||
|
Revision ID: cd31303a681c
|
||||||
|
Revises: 12efff9b8e0c
|
||||||
|
Create Date: 2026-02-17 00:34:52.644231
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'cd31303a681c'
|
||||||
|
down_revision = '12efff9b8e0c'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
24
migrations/versions/20260218_0402_12_merge_migration.py
Normal file
24
migrations/versions/20260218_0402_12_merge_migration.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""merge branches
|
||||||
|
|
||||||
|
Revision ID: merge_migration
|
||||||
|
Revises: cd31303a681c
|
||||||
|
Create Date: 2026-02-18 04:02:12.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'merge_migration'
|
||||||
|
down_revision = 'cd31303a681c'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
pass
|
||||||
45
migrations/versions/20260307_0100_add_emoji_mappings.py
Normal file
45
migrations/versions/20260307_0100_add_emoji_mappings.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""Add emoji_mappings table for storing custom emoji IDs
|
||||||
|
|
||||||
|
Revision ID: 20260307_0100_add_emoji_mappings
|
||||||
|
Revises: merge_migration
|
||||||
|
Create Date: 2026-03-07 01:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '20260307_0100_add_emoji_mappings'
|
||||||
|
down_revision = 'merge_migration'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
'emoji_mappings',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('emoji_text', sa.String(length=10), nullable=False),
|
||||||
|
sa.Column('emoji_id', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('admin_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('description', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['admin_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('emoji_id', name='emoji_mappings_emoji_id_key'),
|
||||||
|
sa.UniqueConstraint('emoji_text', 'admin_id', name='unique_emoji_per_admin'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_emoji_mappings_emoji_id', 'emoji_mappings', ['emoji_id'], unique=True)
|
||||||
|
op.create_index('ix_emoji_mappings_emoji_text', 'emoji_mappings', ['emoji_text'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index('ix_emoji_mappings_emoji_text', table_name='emoji_mappings')
|
||||||
|
op.drop_index('ix_emoji_mappings_emoji_id', table_name='emoji_mappings')
|
||||||
|
op.drop_table('emoji_mappings')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -5,4 +5,8 @@ sqlalchemy==2.0.36
|
|||||||
alembic==1.14.0
|
alembic==1.14.0
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
asyncpg==0.30.0
|
asyncpg==0.30.0
|
||||||
aiosqlite==0.20.0
|
aiosqlite==0.20.0
|
||||||
|
redis==5.2.1
|
||||||
|
aioredis==2.0.1
|
||||||
|
apscheduler==3.10.4
|
||||||
|
openpyxl==3.1.2
|
||||||
102
scripts/backup_db.sh
Normal file
102
scripts/backup_db.sh
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Скрипт резервного копирования БД PostgreSQL
|
||||||
|
# Использование: ./backup_db.sh
|
||||||
|
# Для автоматизации добавьте в crontab: 0 3 * * * /path/to/backup_db.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Переменные
|
||||||
|
BACKUP_DIR="${HOME}/new_lottery_bot/backups"
|
||||||
|
DB_NAME="${DATABASE_DEFAULT:-lottery_bot}"
|
||||||
|
DB_USER="${DATABASE_USER:-trevor}"
|
||||||
|
DB_HOST="${DATABASE_HOST:-localhost}"
|
||||||
|
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||||
|
BACKUP_FILE="${BACKUP_DIR}/lottery_bot_${TIMESTAMP}.sql.gz"
|
||||||
|
KEEP_DAYS=7 # Хранить резервные копии 7 дней
|
||||||
|
|
||||||
|
# Цвета
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
log_info() {
|
||||||
|
echo -e "${GREEN}✅ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warn() {
|
||||||
|
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}❌ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "🔄 Резервное копирование БД PostgreSQL"
|
||||||
|
echo "========================================"
|
||||||
|
|
||||||
|
# Создание директории для резервных копий
|
||||||
|
if [ ! -d "$BACKUP_DIR" ]; then
|
||||||
|
log_info "Создание директории для резервных копий..."
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Получение размера БД перед резервной копией
|
||||||
|
DB_SIZE=$(psql -h "$DB_HOST" -U "$DB_USER" -t -c "
|
||||||
|
SELECT pg_size_pretty(pg_database.datsize)
|
||||||
|
FROM pg_database
|
||||||
|
WHERE datname = '$DB_NAME';
|
||||||
|
")
|
||||||
|
|
||||||
|
log_info "База данных: $DB_NAME"
|
||||||
|
log_info "Размер БД: $DB_SIZE"
|
||||||
|
log_info "Файл резервной копии: $BACKUP_FILE"
|
||||||
|
|
||||||
|
# Создание резервной копии
|
||||||
|
echo ""
|
||||||
|
echo "⏳ Выполнение резервной копии..."
|
||||||
|
|
||||||
|
if pg_dump -h "$DB_HOST" -U "$DB_USER" "$DB_NAME" | gzip > "$BACKUP_FILE" 2>/dev/null; then
|
||||||
|
BACKUP_SIZE=$(ls -lh "$BACKUP_FILE" | awk '{print $5}')
|
||||||
|
log_info "Резервная копия создана успешно"
|
||||||
|
log_info "Размер файла: $BACKUP_SIZE"
|
||||||
|
else
|
||||||
|
log_error "Ошибка при создании резервной копии"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Удаление старых резервных копий
|
||||||
|
echo ""
|
||||||
|
echo "🧹 Удаление старых резервных копий..."
|
||||||
|
find "$BACKUP_DIR" -name "lottery_bot_*.sql.gz" -mtime +$KEEP_DAYS -exec rm -f {} \;
|
||||||
|
log_info "Очистка завершена (хранятся копии за последние $KEEP_DAYS дней)"
|
||||||
|
|
||||||
|
# Статистика
|
||||||
|
echo ""
|
||||||
|
echo "📊 Статистика резервных копий:"
|
||||||
|
TOTAL_SIZE=$(du -sh "$BACKUP_DIR" | awk '{print $1}')
|
||||||
|
COUNT=$(ls -1 "$BACKUP_DIR"/lottery_bot_*.sql.gz 2>/dev/null | wc -l)
|
||||||
|
log_info "Всего резервных копий: $COUNT"
|
||||||
|
log_info "Общий размер: $TOTAL_SIZE"
|
||||||
|
|
||||||
|
# Информация о последних копиях
|
||||||
|
echo ""
|
||||||
|
echo "📋 Последние 5 резервных копий:"
|
||||||
|
ls -1t "$BACKUP_DIR"/lottery_bot_*.sql.gz 2>/dev/null | head -5 | while read file; do
|
||||||
|
size=$(ls -lh "$file" | awk '{print $5}')
|
||||||
|
name=$(basename "$file")
|
||||||
|
echo " • $name ($size)"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
log_info "Резервная копия завершена!"
|
||||||
|
|
||||||
|
# Дополнительная информация
|
||||||
|
echo ""
|
||||||
|
echo "💡 Советы:"
|
||||||
|
echo " • Важные копии загружайте на облако"
|
||||||
|
echo " • Тестируйте восстановление из копий"
|
||||||
|
echo " • Добавьте в crontab для автоматизации:"
|
||||||
|
echo " 0 3 * * * $PWD/scripts/backup_db.sh"
|
||||||
|
echo ""
|
||||||
134
scripts/check_db.py
Normal file
134
scripts/check_db.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Скрипт для проверки и инициализации БД перед запуском бота
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Добавляем путь к проекту
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from src.core.database import engine, async_session_maker, Base
|
||||||
|
from src.core.models import User, Lottery, Participation, Winner, Account
|
||||||
|
from sqlalchemy import text, inspect, select
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_db_connection():
|
||||||
|
"""Проверка подключения к БД"""
|
||||||
|
logger.info("🔍 Проверка подключения к БД...")
|
||||||
|
try:
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
result = await session.execute(text("SELECT 1"))
|
||||||
|
logger.info("✅ Подключение к БД успешно")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка подключения к БД: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def check_tables():
|
||||||
|
"""Проверка наличия таблиц"""
|
||||||
|
logger.info("📊 Проверка таблиц БД...")
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
inspector = inspect(conn)
|
||||||
|
tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
required_tables = ['users', 'lotteries', 'participations', 'winners', 'accounts']
|
||||||
|
|
||||||
|
missing_tables = [t for t in required_tables if t not in tables]
|
||||||
|
|
||||||
|
if missing_tables:
|
||||||
|
logger.warning(f"⚠️ Отсутствуют таблицы: {', '.join(missing_tables)}")
|
||||||
|
return False, missing_tables
|
||||||
|
else:
|
||||||
|
logger.info(f"✅ Все необходимые таблицы найдены: {', '.join(required_tables)}")
|
||||||
|
return True, []
|
||||||
|
|
||||||
|
|
||||||
|
async def create_tables():
|
||||||
|
"""Создание таблиц БД"""
|
||||||
|
logger.info("📝 Создание таблиц БД...")
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
logger.info("✅ Таблицы созданы успешно")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при создании таблиц: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def check_data():
|
||||||
|
"""Проверка наличия данных"""
|
||||||
|
logger.info("📈 Проверка данных в БД...")
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
users_count = await session.execute(select(User))
|
||||||
|
users_count = len(users_count.scalars().all())
|
||||||
|
|
||||||
|
lotteries_count = await session.execute(select(Lottery))
|
||||||
|
lotteries_count = len(lotteries_count.scalars().all())
|
||||||
|
|
||||||
|
logger.info(f"👥 Пользователей: {users_count}")
|
||||||
|
logger.info(f"🎲 Розыгрышей: {lotteries_count}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'users': users_count,
|
||||||
|
'lotteries': lotteries_count
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Главная функция"""
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("🔧 Проверка и инициализация БД")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
# Шаг 1: Проверка подключения
|
||||||
|
if not await check_db_connection():
|
||||||
|
logger.error("❌ Не удалось подключиться к БД. Проверьте переменную DATABASE_URL")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Шаг 2: Проверка таблиц
|
||||||
|
tables_exist, missing_tables = await check_tables()
|
||||||
|
|
||||||
|
if not tables_exist:
|
||||||
|
logger.info("🔄 Создание отсутствующих таблиц...")
|
||||||
|
if not await create_tables():
|
||||||
|
logger.error("❌ Не удалось создать таблицы")
|
||||||
|
return False
|
||||||
|
logger.info("✅ Таблицы созданы")
|
||||||
|
|
||||||
|
# Шаг 3: Проверка данных
|
||||||
|
data = await check_data()
|
||||||
|
|
||||||
|
# Итоговая информация
|
||||||
|
logger.info("")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("✅ БД готова к работе!")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("")
|
||||||
|
logger.info("📋 Информация о БД:")
|
||||||
|
logger.info(f" 👥 Пользователей: {data['users']}")
|
||||||
|
logger.info(f" 🎲 Розыгрышей: {data['lotteries']}")
|
||||||
|
logger.info("")
|
||||||
|
logger.info("🚀 Вы можете запустить бота командой:")
|
||||||
|
logger.info(" python3 main.py")
|
||||||
|
logger.info("")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
success = asyncio.run(main())
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
53
scripts/deploy_and_run.sh
Normal file
53
scripts/deploy_and_run.sh
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Быстрый запуск: deploy_and_run.sh
|
||||||
|
# Выполняет развертывание и запуск бота одной командой
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🚀 Lottery Bot - Быстрое развертывание и запуск"
|
||||||
|
echo "=================================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Проверка .env
|
||||||
|
if [ ! -f ".env" ]; then
|
||||||
|
echo "❌ Файл .env не найден!"
|
||||||
|
echo ""
|
||||||
|
echo "Создайте .env файл с содержимым:"
|
||||||
|
echo "────────────────────────────────────────────"
|
||||||
|
cat << 'EOF'
|
||||||
|
BOT_TOKEN=your_bot_token
|
||||||
|
DATABASE_URL=postgresql://trevor:password@localhost:5432/lottery_bot
|
||||||
|
ADMIN_IDS=123456789
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
EOF
|
||||||
|
echo "────────────────────────────────────────────"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Файл .env найден"
|
||||||
|
|
||||||
|
# Создание виртуального окружения
|
||||||
|
if [ ! -d "venv" ]; then
|
||||||
|
echo "📦 Создание виртуального окружения..."
|
||||||
|
python3 -m venv venv
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Активация
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Установка dependencies
|
||||||
|
echo "📚 Установка dependencies..."
|
||||||
|
pip3 install -q --upgrade pip
|
||||||
|
pip3 install -q -r requirements.txt
|
||||||
|
|
||||||
|
# Проверка БД
|
||||||
|
echo "🗄️ Проверка и инициализация БД..."
|
||||||
|
python3 scripts/check_db.py
|
||||||
|
|
||||||
|
# Запуск бота
|
||||||
|
echo ""
|
||||||
|
echo "🤖 Запуск бота..."
|
||||||
|
echo "=================================================="
|
||||||
|
echo ""
|
||||||
|
python3 main.py
|
||||||
172
scripts/deploy_server.sh
Normal file
172
scripts/deploy_server.sh
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Скрипт развертывания lottery_bot на сервере
|
||||||
|
# Использование: ./deploy_server.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🔧 ============================================"
|
||||||
|
echo "🔧 Развертывание Lottery Bot на сервер"
|
||||||
|
echo "🔧 ============================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Цвета для вывода
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Функция для вывода сообщений
|
||||||
|
log_info() {
|
||||||
|
echo -e "${GREEN}✅ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warn() {
|
||||||
|
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}❌ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Проверка переменных окружения
|
||||||
|
if [ -z "$DATABASE_URL" ]; then
|
||||||
|
log_error "DATABASE_URL не установлен в .env"
|
||||||
|
echo "Пример: export DATABASE_URL='postgresql://user:password@host:5432/lottery_bot'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$BOT_TOKEN" ]; then
|
||||||
|
log_error "BOT_TOKEN не установлен в .env"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Переменные окружения проверены"
|
||||||
|
|
||||||
|
# 1. Проверка зависимостей
|
||||||
|
echo ""
|
||||||
|
echo "📦 Проверка зависимостей..."
|
||||||
|
|
||||||
|
if ! command -v python3 &> /dev/null; then
|
||||||
|
log_error "Python 3 не установлен"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log_info "Python 3 найден: $(python3 --version)"
|
||||||
|
|
||||||
|
if ! command -v pip3 &> /dev/null; then
|
||||||
|
log_error "pip3 не установлен"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log_info "pip3 установлен"
|
||||||
|
|
||||||
|
# 2. Создание виртуального окружения
|
||||||
|
echo ""
|
||||||
|
echo "🐍 Подготовка виртуального окружения..."
|
||||||
|
|
||||||
|
if [ ! -d "venv" ]; then
|
||||||
|
log_info "Создание виртуального окружения..."
|
||||||
|
python3 -m venv venv
|
||||||
|
else
|
||||||
|
log_warn "Виртуальное окружение уже существует"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Активируем виртуальное окружение
|
||||||
|
source venv/bin/activate
|
||||||
|
log_info "Виртуальное окружение активировано"
|
||||||
|
|
||||||
|
# 3. Установка зависимостей
|
||||||
|
echo ""
|
||||||
|
echo "📚 Установка зависимостей из requirements.txt..."
|
||||||
|
|
||||||
|
if [ -f "requirements.txt" ]; then
|
||||||
|
pip3 install --upgrade pip setuptools wheel -q
|
||||||
|
pip3 install -r requirements.txt -q
|
||||||
|
log_info "Зависимости установлены"
|
||||||
|
else
|
||||||
|
log_error "requirements.txt не найден"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Проверка подключения к БД
|
||||||
|
echo ""
|
||||||
|
echo "🗄️ Проверка подключения к базе данных..."
|
||||||
|
|
||||||
|
python3 << 'EOF'
|
||||||
|
import asyncio
|
||||||
|
from src.core.database import async_session_maker
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
async def test_db():
|
||||||
|
try:
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
result = await session.execute(text("SELECT 1"))
|
||||||
|
print("✅ Подключение к БД успешно")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Ошибка подключения: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not asyncio.run(test_db()):
|
||||||
|
exit(1)
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
log_error "Не удалось подключиться к базе данных"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Запуск миграций
|
||||||
|
echo ""
|
||||||
|
echo "📝 Запуск миграций базы данных..."
|
||||||
|
|
||||||
|
if command -v alembic &> /dev/null; then
|
||||||
|
log_info "Alembic найден, запуск миграций..."
|
||||||
|
alembic upgrade head
|
||||||
|
log_info "Миграции завершены"
|
||||||
|
else
|
||||||
|
log_warn "Alembic не найден, пропуск миграций Alembic"
|
||||||
|
|
||||||
|
# Используем встроенный скрипт инициализации
|
||||||
|
if [ -f "scripts/db_setup.py" ]; then
|
||||||
|
log_info "Использование скрипта инициализации БД..."
|
||||||
|
python3 scripts/db_setup.py
|
||||||
|
log_info "БД инициализирована"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 6. Проверка конфигурации
|
||||||
|
echo ""
|
||||||
|
echo "⚙️ Проверка конфигурации..."
|
||||||
|
|
||||||
|
python3 << 'EOF'
|
||||||
|
from src.core.config import BOT_TOKEN, DATABASE_URL, ADMIN_IDS
|
||||||
|
|
||||||
|
print(f"✅ BOT_TOKEN загружен")
|
||||||
|
print(f"✅ DATABASE_URL: {DATABASE_URL[:50]}...")
|
||||||
|
print(f"✅ ADMIN_IDS: {ADMIN_IDS if ADMIN_IDS else 'Не установлены'}")
|
||||||
|
EOF
|
||||||
|
|
||||||
|
log_info "Конфигурация проверена"
|
||||||
|
|
||||||
|
# 7. Информация о запуске
|
||||||
|
echo ""
|
||||||
|
echo "🚀 ============================================"
|
||||||
|
echo "🚀 Приложение готово к запуску"
|
||||||
|
echo "🚀 ============================================"
|
||||||
|
echo ""
|
||||||
|
echo "📋 Команды для запуска:"
|
||||||
|
echo ""
|
||||||
|
echo "Режим разработки:"
|
||||||
|
echo " python3 main.py"
|
||||||
|
echo ""
|
||||||
|
echo "Производство (с systemd):"
|
||||||
|
echo " sudo systemctl start lottery-bot"
|
||||||
|
echo " sudo systemctl enable lottery-bot"
|
||||||
|
echo ""
|
||||||
|
echo "Docker:"
|
||||||
|
echo " docker-compose up -d"
|
||||||
|
echo ""
|
||||||
|
echo "⚙️ Переменные окружения:"
|
||||||
|
echo " DATABASE_URL: $(echo $DATABASE_URL | cut -c1-50)..."
|
||||||
|
echo " BOT_TOKEN: $(echo $BOT_TOKEN | cut -c1-20)...${BOT_TOKEN: -5}"
|
||||||
|
echo ""
|
||||||
|
log_info "Развертывание завершено!"
|
||||||
158
scripts/manage_admins.py
Normal file
158
scripts/manage_admins.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Скрипт для управления администраторами через CLI
|
||||||
|
Используется для быстрого доступа к функциям управления админами
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Добавляем путь к проекту
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from src.core.database import async_session_maker
|
||||||
|
from src.core.services import UserService
|
||||||
|
from src.core.config import ADMIN_IDS
|
||||||
|
|
||||||
|
|
||||||
|
async def list_admins():
|
||||||
|
"""Показать список всех администраторов"""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
from sqlalchemy import select
|
||||||
|
from src.core.models import User
|
||||||
|
|
||||||
|
# Получаем всех администраторов из БД
|
||||||
|
result = await session.execute(
|
||||||
|
select(User).where(User.is_admin == True).order_by(User.created_at.desc())
|
||||||
|
)
|
||||||
|
db_admins = result.scalars().all()
|
||||||
|
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("👑 СПИСОК АДМИНИСТРАТОРОВ")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
print("\n🔴 Главные администраторы (.env):")
|
||||||
|
if ADMIN_IDS:
|
||||||
|
for admin_id in ADMIN_IDS:
|
||||||
|
print(f" • ID: {admin_id}")
|
||||||
|
else:
|
||||||
|
print(" Нет главных администраторов")
|
||||||
|
|
||||||
|
print("\n🟠 Назначенные администраторы:")
|
||||||
|
if db_admins:
|
||||||
|
for admin in db_admins:
|
||||||
|
name = admin.first_name or admin.username or f"@ID_{admin.telegram_id}"
|
||||||
|
print(f" • {name} (Telegram ID: {admin.telegram_id})")
|
||||||
|
else:
|
||||||
|
print(" Нет назначенных администраторов")
|
||||||
|
|
||||||
|
print("\n" + "="*60 + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
async def add_admin(telegram_id: int):
|
||||||
|
"""Добавить администратора"""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
# Проверяем, существует ли пользователь
|
||||||
|
user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
print(f"❌ Пользователь с ID {telegram_id} не найден")
|
||||||
|
return
|
||||||
|
|
||||||
|
if telegram_id in ADMIN_IDS:
|
||||||
|
print(f"❌ ID {telegram_id} - это главный администратор (.env)")
|
||||||
|
return
|
||||||
|
|
||||||
|
if user.is_admin:
|
||||||
|
print(f"❌ Пользователь {user.first_name or user.username} уже администратор")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Назначаем админа
|
||||||
|
success = await UserService.set_admin(session, telegram_id, is_admin=True)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
name = user.first_name or user.username or f"@ID_{telegram_id}"
|
||||||
|
print(f"✅ {name} назначен администратором")
|
||||||
|
else:
|
||||||
|
print(f"❌ Ошибка при назначении администратора")
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_admin(telegram_id: int):
|
||||||
|
"""Удалить администратора"""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
if telegram_id in ADMIN_IDS:
|
||||||
|
print(f"❌ Нельзя удалить главного администратора (.env)")
|
||||||
|
print(f" Для изменения отредактируйте .env")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Проверяем, существует ли пользователь
|
||||||
|
user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
print(f"❌ Пользователь с ID {telegram_id} не найден")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not user.is_admin:
|
||||||
|
print(f"❌ Пользователь {user.first_name or user.username} не является администратором")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Удаляем админа
|
||||||
|
success = await UserService.set_admin(session, telegram_id, is_admin=False)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
name = user.first_name or user.username or f"@ID_{telegram_id}"
|
||||||
|
print(f"✅ Права администратора удалены у {name}")
|
||||||
|
else:
|
||||||
|
print(f"❌ Ошибка при удалении прав администратора")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Главная функция"""
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("""
|
||||||
|
Использование: python scripts/manage_admins.py <команда> [аргументы]
|
||||||
|
|
||||||
|
Команды:
|
||||||
|
list - Показать список всех администраторов
|
||||||
|
add <id> - Добавить администратора (по Telegram ID)
|
||||||
|
remove <id> - Удалить администратора (по Telegram ID)
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
python scripts/manage_admins.py list
|
||||||
|
python scripts/manage_admins.py add 123456789
|
||||||
|
python scripts/manage_admins.py remove 123456789
|
||||||
|
""")
|
||||||
|
return
|
||||||
|
|
||||||
|
command = sys.argv[1].lower()
|
||||||
|
|
||||||
|
if command == "list":
|
||||||
|
await list_admins()
|
||||||
|
|
||||||
|
elif command == "add":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("❌ Требуется указать Telegram ID")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
telegram_id = int(sys.argv[2])
|
||||||
|
await add_admin(telegram_id)
|
||||||
|
except ValueError:
|
||||||
|
print("❌ Telegram ID должен быть числом")
|
||||||
|
|
||||||
|
elif command == "remove":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("❌ Требуется указать Telegram ID")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
telegram_id = int(sys.argv[2])
|
||||||
|
await remove_admin(telegram_id)
|
||||||
|
except ValueError:
|
||||||
|
print("❌ Telegram ID должен быть числом")
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"❌ Неизвестная команда: {command}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -11,8 +11,9 @@ class KeyboardBuilderImpl(IKeyboardBuilder):
|
|||||||
def get_main_keyboard(self, is_admin: bool = False, is_registered: bool = False):
|
def get_main_keyboard(self, is_admin: bool = False, is_registered: bool = False):
|
||||||
"""Получить главную клавиатуру"""
|
"""Получить главную клавиатуру"""
|
||||||
buttons = [
|
buttons = [
|
||||||
[InlineKeyboardButton(text="🎲 Активные розыгрыши", callback_data="active_lotteries")],
|
[InlineKeyboardButton(text="🎰 Активные розыгрыши", callback_data="active_lotteries")],
|
||||||
[InlineKeyboardButton(text="💬 Войти в чат", callback_data="enter_chat")]
|
[InlineKeyboardButton(text="💬 Войти в чат", callback_data="enter_chat")],
|
||||||
|
[InlineKeyboardButton(text="❓ Справка", callback_data="help_main")]
|
||||||
]
|
]
|
||||||
|
|
||||||
# Показываем кнопку регистрации только незарегистрированным пользователям (не админам)
|
# Показываем кнопку регистрации только незарегистрированным пользователям (не админам)
|
||||||
@@ -22,7 +23,7 @@ class KeyboardBuilderImpl(IKeyboardBuilder):
|
|||||||
if is_admin:
|
if is_admin:
|
||||||
buttons.extend([
|
buttons.extend([
|
||||||
[InlineKeyboardButton(text="⚙️ Админ панель", callback_data="admin_panel")],
|
[InlineKeyboardButton(text="⚙️ Админ панель", callback_data="admin_panel")],
|
||||||
[InlineKeyboardButton(text="➕ Создать розыгрыш", callback_data="admin_create_lottery")]
|
[InlineKeyboardButton(text="✨ Создать розыгрыш", callback_data="admin_create_lottery")]
|
||||||
])
|
])
|
||||||
|
|
||||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
@@ -30,13 +31,14 @@ class KeyboardBuilderImpl(IKeyboardBuilder):
|
|||||||
def get_admin_keyboard(self):
|
def get_admin_keyboard(self):
|
||||||
"""Получить админскую клавиатуру"""
|
"""Получить админскую клавиатуру"""
|
||||||
buttons = [
|
buttons = [
|
||||||
[InlineKeyboardButton(text="🎲 Управление розыгрышами", callback_data="admin_lotteries")],
|
[InlineKeyboardButton(text="🎰 Розыгрыши", callback_data="admin_lotteries"),
|
||||||
[InlineKeyboardButton(text="👥 Управление участниками", callback_data="admin_participants")],
|
InlineKeyboardButton(text="🏆 Победители", callback_data="admin_winners")],
|
||||||
[InlineKeyboardButton(text="👑 Управление победителями", callback_data="admin_winners")],
|
[InlineKeyboardButton(text="📋 Участники", callback_data="admin_participants"),
|
||||||
[InlineKeyboardButton(text="💬 Сообщения пользователей", callback_data="admin_messages")],
|
InlineKeyboardButton(text="👥 Пользователи", callback_data="admin_users")],
|
||||||
[InlineKeyboardButton(text="📊 Статистика", callback_data="admin_stats")],
|
[InlineKeyboardButton(text="📢 Рассылки", callback_data="admin_broadcast"),
|
||||||
|
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_stats")],
|
||||||
[InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_settings")],
|
[InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_settings")],
|
||||||
[InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_main")]
|
[InlineKeyboardButton(text="◀️ Назад", callback_data="back_to_main")]
|
||||||
]
|
]
|
||||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|
||||||
@@ -52,12 +54,12 @@ class MessageFormatterImpl(IMessageFormatter):
|
|||||||
|
|
||||||
if is_admin:
|
if is_admin:
|
||||||
buttons.extend([
|
buttons.extend([
|
||||||
[InlineKeyboardButton(text="🎲 Провести розыгрыш", callback_data=f"conduct_{lottery_id}")],
|
[InlineKeyboardButton(text="🎰 Провести розыгрыш", callback_data=f"conduct_{lottery_id}")],
|
||||||
[InlineKeyboardButton(text="✏️ Редактировать", callback_data=f"edit_{lottery_id}")],
|
[InlineKeyboardButton(text="✏️ Редактировать", callback_data=f"edit_{lottery_id}")],
|
||||||
[InlineKeyboardButton(text="❌ Удалить", callback_data=f"delete_{lottery_id}")]
|
[InlineKeyboardButton(text="❌ Удалить", callback_data=f"delete_{lottery_id}")]
|
||||||
])
|
])
|
||||||
|
|
||||||
buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="active_lotteries")])
|
buttons.append([InlineKeyboardButton(text="◀️ Назад", callback_data="active_lotteries")])
|
||||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|
||||||
def get_conduct_lottery_keyboard(self, lotteries: List[Lottery]):
|
def get_conduct_lottery_keyboard(self, lotteries: List[Lottery]):
|
||||||
@@ -70,7 +72,7 @@ class MessageFormatterImpl(IMessageFormatter):
|
|||||||
text = text[:47] + "..."
|
text = text[:47] + "..."
|
||||||
buttons.append([InlineKeyboardButton(text=text, callback_data=f"conduct_{lottery.id}")])
|
buttons.append([InlineKeyboardButton(text=text, callback_data=f"conduct_{lottery.id}")])
|
||||||
|
|
||||||
buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="lottery_management")])
|
buttons.append([InlineKeyboardButton(text="◀️ Назад", callback_data="lottery_management")])
|
||||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import logging
|
|||||||
from src.interfaces.base import IBotController, ILotteryService, IUserService, IKeyboardBuilder, IMessageFormatter
|
from src.interfaces.base import IBotController, ILotteryService, IUserService, IKeyboardBuilder, IMessageFormatter
|
||||||
from src.interfaces.base import ILotteryRepository, IParticipationRepository
|
from src.interfaces.base import ILotteryRepository, IParticipationRepository
|
||||||
from src.core.config import ADMIN_IDS
|
from src.core.config import ADMIN_IDS
|
||||||
|
from src.core.telegram_config import get_parse_mode
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -34,6 +35,11 @@ class BotController(IBotController):
|
|||||||
|
|
||||||
async def handle_start(self, message: Message):
|
async def handle_start(self, message: Message):
|
||||||
"""Обработать команду /start"""
|
"""Обработать команду /start"""
|
||||||
|
from src.utils.keyboards import get_main_reply_keyboard
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
user = await self.user_service.get_or_create_user(
|
user = await self.user_service.get_or_create_user(
|
||||||
telegram_id=message.from_user.id,
|
telegram_id=message.from_user.id,
|
||||||
username=message.from_user.username,
|
username=message.from_user.username,
|
||||||
@@ -41,6 +47,9 @@ class BotController(IBotController):
|
|||||||
last_name=message.from_user.last_name
|
last_name=message.from_user.last_name
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Логирование статуса регистрации
|
||||||
|
logger.info(f"User {message.from_user.id}: is_registered={user.is_registered}, is_admin={self.is_admin(message.from_user.id)}")
|
||||||
|
|
||||||
welcome_text = f"👋 Добро пожаловать, {user.first_name or 'дорогой пользователь'}!\n\n"
|
welcome_text = f"👋 Добро пожаловать, {user.first_name or 'дорогой пользователь'}!\n\n"
|
||||||
welcome_text += "🎲 Это бот для участия в розыгрышах.\n\n"
|
welcome_text += "🎲 Это бот для участия в розыгрышах.\n\n"
|
||||||
|
|
||||||
@@ -49,14 +58,27 @@ class BotController(IBotController):
|
|||||||
else:
|
else:
|
||||||
welcome_text += "📝 Для участия в розыгрышах необходимо зарегистрироваться."
|
welcome_text += "📝 Для участия в розыгрышах необходимо зарегистрироваться."
|
||||||
|
|
||||||
keyboard = self.keyboard_builder.get_main_keyboard(
|
# Inline клавиатура
|
||||||
|
inline_keyboard = self.keyboard_builder.get_main_keyboard(
|
||||||
|
is_admin=self.is_admin(message.from_user.id),
|
||||||
|
is_registered=user.is_registered
|
||||||
|
)
|
||||||
|
|
||||||
|
# Обычная клавиатура
|
||||||
|
reply_keyboard = get_main_reply_keyboard(
|
||||||
is_admin=self.is_admin(message.from_user.id),
|
is_admin=self.is_admin(message.from_user.id),
|
||||||
is_registered=user.is_registered
|
is_registered=user.is_registered
|
||||||
)
|
)
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
welcome_text,
|
welcome_text,
|
||||||
reply_markup=keyboard
|
reply_markup=reply_keyboard # Обычная клавиатура
|
||||||
|
)
|
||||||
|
|
||||||
|
# Отправляем inline клавиатуру отдельным сообщением
|
||||||
|
await message.answer(
|
||||||
|
"Выберите действие:",
|
||||||
|
reply_markup=inline_keyboard
|
||||||
)
|
)
|
||||||
|
|
||||||
async def handle_active_lotteries(self, callback: CallbackQuery):
|
async def handle_active_lotteries(self, callback: CallbackQuery):
|
||||||
@@ -67,7 +89,7 @@ class BotController(IBotController):
|
|||||||
await callback.answer("❌ Нет активных розыгрышей", show_alert=True)
|
await callback.answer("❌ Нет активных розыгрышей", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
text = "🎲 **Активные розыгрыши:**\n\n"
|
text = "🎲 <b>Активные розыгрыши:</b>\n\n"
|
||||||
|
|
||||||
for lottery in lotteries:
|
for lottery in lotteries:
|
||||||
participants_count = await self.participation_repo.get_count_by_lottery(lottery.id)
|
participants_count = await self.participation_repo.get_count_by_lottery(lottery.id)
|
||||||
@@ -91,7 +113,7 @@ class BotController(IBotController):
|
|||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
text,
|
text,
|
||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
parse_mode="Markdown"
|
parse_mode=get_parse_mode("inline_keyboard")
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Если сообщение не изменилось - просто отвечаем на callback
|
# Если сообщение не изменилось - просто отвечаем на callback
|
||||||
@@ -103,5 +125,5 @@ class BotController(IBotController):
|
|||||||
await callback.message.answer(
|
await callback.message.answer(
|
||||||
text,
|
text,
|
||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
parse_mode="Markdown"
|
parse_mode=get_parse_mode("inline_keyboard")
|
||||||
)
|
)
|
||||||
|
|||||||
176
src/core/activity_service.py
Normal file
176
src/core/activity_service.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"""
|
||||||
|
Сервис для отслеживания активности пользователей
|
||||||
|
и автоматической блокировки неактивных
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from sqlalchemy import select, and_, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from typing import List
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .models import User, BlockedUser
|
||||||
|
from .database import async_session_maker
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityService:
|
||||||
|
"""Сервис для управления активностью пользователей"""
|
||||||
|
|
||||||
|
# Период неактивности в днях (по умолчанию 30 дней)
|
||||||
|
INACTIVITY_PERIOD_DAYS = 30
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def update_user_activity(session: AsyncSession, telegram_id: int) -> None:
|
||||||
|
"""
|
||||||
|
Обновить last_activity для пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Сессия БД
|
||||||
|
telegram_id: Telegram ID пользователя
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
stmt = (
|
||||||
|
update(User)
|
||||||
|
.where(User.telegram_id == telegram_id)
|
||||||
|
.values(last_activity=datetime.now(timezone.utc))
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка обновления активности пользователя {telegram_id}: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_inactive_users(
|
||||||
|
session: AsyncSession,
|
||||||
|
days: int = None
|
||||||
|
) -> List[User]:
|
||||||
|
"""
|
||||||
|
Получить список неактивных пользователей
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Сессия БД
|
||||||
|
days: Количество дней неактивности (по умолчанию INACTIVITY_PERIOD_DAYS)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список неактивных пользователей
|
||||||
|
"""
|
||||||
|
if days is None:
|
||||||
|
days = ActivityService.INACTIVITY_PERIOD_DAYS
|
||||||
|
|
||||||
|
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
|
||||||
|
|
||||||
|
stmt = select(User).where(
|
||||||
|
and_(
|
||||||
|
User.last_activity < cutoff_date,
|
||||||
|
User.is_registered == True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def mark_inactive_users(session: AsyncSession, days: int = None) -> int:
|
||||||
|
"""
|
||||||
|
Пометить неактивных пользователей как заблокированных
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Сессия БД
|
||||||
|
days: Количество дней неактивности
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Количество помеченных пользователей
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
inactive_users = await ActivityService.get_inactive_users(session, days)
|
||||||
|
marked_count = 0
|
||||||
|
|
||||||
|
for user in inactive_users:
|
||||||
|
# Проверяем, не помечен ли уже
|
||||||
|
stmt = select(BlockedUser).where(
|
||||||
|
and_(
|
||||||
|
BlockedUser.telegram_id == user.telegram_id,
|
||||||
|
BlockedUser.error_type == 'inactive',
|
||||||
|
BlockedUser.is_active == True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
# Создаем новую запись
|
||||||
|
blocked = BlockedUser(
|
||||||
|
telegram_id=user.telegram_id,
|
||||||
|
error_type='inactive',
|
||||||
|
error_message=f'User inactive for {days} days',
|
||||||
|
first_blocked_at=datetime.now(timezone.utc),
|
||||||
|
last_attempt_at=datetime.now(timezone.utc),
|
||||||
|
attempt_count=1,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
session.add(blocked)
|
||||||
|
marked_count += 1
|
||||||
|
logger.info(f"Пользователь {user.telegram_id} помечен как неактивный (последняя активность: {user.last_activity})")
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return marked_count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при пометке неактивных пользователей: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def reactivate_user(session: AsyncSession, telegram_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Реактивировать пользователя (убрать из списка заблокированных по неактивности)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Сессия БД
|
||||||
|
telegram_id: Telegram ID пользователя
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если пользователь реактивирован
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Обновляем активность
|
||||||
|
await ActivityService.update_user_activity(session, telegram_id)
|
||||||
|
|
||||||
|
# Деактивируем запись о блокировке по неактивности
|
||||||
|
stmt = (
|
||||||
|
update(BlockedUser)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
BlockedUser.telegram_id == telegram_id,
|
||||||
|
BlockedUser.error_type == 'inactive',
|
||||||
|
BlockedUser.is_active == True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.values(is_active=False)
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Пользователь {telegram_id} реактивирован")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка реактивации пользователя {telegram_id}: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def check_and_mark_inactive_users() -> int:
|
||||||
|
"""
|
||||||
|
Проверить и пометить всех неактивных пользователей
|
||||||
|
Используется для периодического запуска
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Количество помеченных пользователей
|
||||||
|
"""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
marked = await ActivityService.mark_inactive_users(session)
|
||||||
|
logger.info(f"Проверка неактивных пользователей завершена. Помечено: {marked}")
|
||||||
|
return marked
|
||||||
495
src/core/broadcast_services.py
Normal file
495
src/core/broadcast_services.py
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
"""
|
||||||
|
Сервисы для системы рассылок с поддержкой Redis очередей
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Optional, List, Dict, Tuple, Any
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from aiogram import Bot
|
||||||
|
from aiogram.types import Message
|
||||||
|
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
import redis.asyncio as redis
|
||||||
|
|
||||||
|
from .models import User, BlockedUser, BroadcastLog, BroadcastChannel
|
||||||
|
from .config import REDIS_URL, ADMIN_IDS
|
||||||
|
from .database import async_session_maker
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RedisQueue:
|
||||||
|
"""Класс для работы с Redis очередями"""
|
||||||
|
|
||||||
|
def __init__(self, redis_url: str = REDIS_URL):
|
||||||
|
self.redis_url = redis_url
|
||||||
|
self._redis: Optional[redis.Redis] = None
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
"""Подключение к Redis"""
|
||||||
|
if self._redis is None:
|
||||||
|
self._redis = await redis.from_url(self.redis_url, decode_responses=False)
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
"""Отключение от Redis"""
|
||||||
|
if self._redis:
|
||||||
|
await self._redis.close()
|
||||||
|
self._redis = None
|
||||||
|
|
||||||
|
async def add_to_queue(self, queue_name: str, data: Dict) -> int:
|
||||||
|
"""
|
||||||
|
Добавить элемент в очередь
|
||||||
|
|
||||||
|
Args:
|
||||||
|
queue_name: Название очереди
|
||||||
|
data: Данные для добавления
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Длина очереди после добавления
|
||||||
|
"""
|
||||||
|
await self.connect()
|
||||||
|
serialized = json.dumps(data).encode('utf-8')
|
||||||
|
return await self._redis.rpush(queue_name, serialized)
|
||||||
|
|
||||||
|
async def get_from_queue(self, queue_name: str, timeout: int = 0) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Получить элемент из очереди (блокирующая операция)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
queue_name: Название очереди
|
||||||
|
timeout: Таймаут ожидания в секундах (0 = бесконечно)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict или None
|
||||||
|
"""
|
||||||
|
await self.connect()
|
||||||
|
result = await self._redis.blpop(queue_name, timeout=timeout)
|
||||||
|
if result:
|
||||||
|
_, data = result
|
||||||
|
return json.loads(data.decode('utf-8'))
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_queue_length(self, queue_name: str) -> int:
|
||||||
|
"""Получить длину очереди"""
|
||||||
|
await self.connect()
|
||||||
|
return await self._redis.llen(queue_name)
|
||||||
|
|
||||||
|
async def clear_queue(self, queue_name: str):
|
||||||
|
"""Очистить очередь"""
|
||||||
|
await self.connect()
|
||||||
|
await self._redis.delete(queue_name)
|
||||||
|
|
||||||
|
|
||||||
|
class BroadcastService:
|
||||||
|
"""Сервис для управления рассылками"""
|
||||||
|
|
||||||
|
# Константы для очередей
|
||||||
|
QUEUE_BROADCAST = "broadcast_queue"
|
||||||
|
QUEUE_FAILED = "broadcast_failed_queue"
|
||||||
|
|
||||||
|
# Лимиты Telegram
|
||||||
|
BATCH_SIZE = 30 # Сообщений в пакете
|
||||||
|
BATCH_DELAY = 1.0 # Задержка между пакетами (секунды)
|
||||||
|
RETRY_AFTER_DELAY = 5.0 # Дополнительная задержка при FloodWait
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.redis_queue = RedisQueue()
|
||||||
|
|
||||||
|
async def check_user_blocked(self, session: AsyncSession, telegram_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Проверить, заблокирован ли пользователь
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Сессия БД
|
||||||
|
telegram_id: Telegram ID пользователя
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True если заблокирован
|
||||||
|
"""
|
||||||
|
stmt = select(BlockedUser).where(
|
||||||
|
BlockedUser.telegram_id == telegram_id,
|
||||||
|
BlockedUser.is_active == True
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
async def mark_user_blocked(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
telegram_id: int,
|
||||||
|
error_type: str,
|
||||||
|
error_message: str
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Отметить пользователя как заблокированного
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Сессия БД
|
||||||
|
telegram_id: Telegram ID пользователя
|
||||||
|
error_type: Тип ошибки
|
||||||
|
error_message: Сообщение об ошибке
|
||||||
|
"""
|
||||||
|
# Проверяем, есть ли уже запись
|
||||||
|
stmt = select(BlockedUser).where(BlockedUser.telegram_id == telegram_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
blocked_user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if blocked_user:
|
||||||
|
# Обновляем существующую запись
|
||||||
|
blocked_user.error_type = error_type
|
||||||
|
blocked_user.error_message = error_message
|
||||||
|
blocked_user.last_attempt_at = datetime.now(timezone.utc)
|
||||||
|
blocked_user.attempt_count += 1
|
||||||
|
blocked_user.is_active = True
|
||||||
|
else:
|
||||||
|
# Создаем новую запись
|
||||||
|
blocked_user = BlockedUser(
|
||||||
|
telegram_id=telegram_id,
|
||||||
|
error_type=error_type,
|
||||||
|
error_message=error_message
|
||||||
|
)
|
||||||
|
session.add(blocked_user)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"Пользователь {telegram_id} отмечен как заблокированный: {error_type}")
|
||||||
|
|
||||||
|
async def unblock_user(self, session: AsyncSession, telegram_id: int):
|
||||||
|
"""
|
||||||
|
Разблокировать пользователя (если сообщение успешно доставлено)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Сессия БД
|
||||||
|
telegram_id: Telegram ID пользователя
|
||||||
|
"""
|
||||||
|
stmt = select(BlockedUser).where(
|
||||||
|
BlockedUser.telegram_id == telegram_id,
|
||||||
|
BlockedUser.is_active == True
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
blocked_user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if blocked_user:
|
||||||
|
blocked_user.is_active = False
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"Пользователь {telegram_id} разблокирован")
|
||||||
|
|
||||||
|
async def send_message_to_user(
|
||||||
|
self,
|
||||||
|
bot: Bot,
|
||||||
|
user: User,
|
||||||
|
message: Message
|
||||||
|
) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Отправить сообщение пользователю с обработкой ошибок
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bot: Инстанс бота
|
||||||
|
user: Объект пользователя
|
||||||
|
message: Сообщение для отправки
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple[bool, Optional[str]]: (успех, тип_ошибки)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Проверяем, не заблокирован ли пользователь
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
is_blocked = await self.check_user_blocked(session, user.telegram_id)
|
||||||
|
if is_blocked:
|
||||||
|
logger.debug(f"Пропускаем заблокированного пользователя {user.telegram_id}")
|
||||||
|
return False, "blocked"
|
||||||
|
|
||||||
|
# Отправляем сообщение
|
||||||
|
if message.text:
|
||||||
|
await bot.send_message(
|
||||||
|
user.telegram_id,
|
||||||
|
message.text,
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
elif message.photo:
|
||||||
|
await bot.send_photo(
|
||||||
|
user.telegram_id,
|
||||||
|
photo=message.photo[-1].file_id,
|
||||||
|
caption=message.caption,
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
elif message.video:
|
||||||
|
await bot.send_video(
|
||||||
|
user.telegram_id,
|
||||||
|
video=message.video.file_id,
|
||||||
|
caption=message.caption,
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
elif message.document:
|
||||||
|
await bot.send_document(
|
||||||
|
user.telegram_id,
|
||||||
|
document=message.document.file_id,
|
||||||
|
caption=message.caption,
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Копируем сообщение как есть
|
||||||
|
await message.copy_to(user.telegram_id)
|
||||||
|
|
||||||
|
# Если успешно - разблокируем пользователя (на случай если он был заблокирован ранее)
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
await self.unblock_user(session, user.telegram_id)
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
except TelegramForbiddenError as e:
|
||||||
|
# Пользователь заблокировал бота
|
||||||
|
error_type = "blocked_bot"
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
await self.mark_user_blocked(session, user.telegram_id, error_type, str(e))
|
||||||
|
return False, error_type
|
||||||
|
|
||||||
|
except TelegramBadRequest as e:
|
||||||
|
# Пользователь удален или деактивирован
|
||||||
|
error_str = str(e).lower()
|
||||||
|
if "user is deactivated" in error_str:
|
||||||
|
error_type = "deactivated"
|
||||||
|
elif "user not found" in error_str:
|
||||||
|
error_type = "not_found"
|
||||||
|
elif "chat not found" in error_str:
|
||||||
|
error_type = "chat_not_found"
|
||||||
|
else:
|
||||||
|
error_type = "bad_request"
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
await self.mark_user_blocked(session, user.telegram_id, error_type, str(e))
|
||||||
|
return False, error_type
|
||||||
|
|
||||||
|
except TelegramRetryAfter as e:
|
||||||
|
# FloodWait - слишком много запросов
|
||||||
|
logger.warning(f"FloodWait для пользователя {user.telegram_id}: ждем {e.retry_after} сек")
|
||||||
|
await asyncio.sleep(e.retry_after + self.RETRY_AFTER_DELAY)
|
||||||
|
# Повторная попытка
|
||||||
|
return await self.send_message_to_user(bot, user, message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Другие ошибки
|
||||||
|
logger.error(f"Ошибка отправки пользователю {user.telegram_id}: {e}")
|
||||||
|
return False, "unknown_error"
|
||||||
|
|
||||||
|
async def broadcast_to_users(
|
||||||
|
self,
|
||||||
|
bot: Bot,
|
||||||
|
message: Message,
|
||||||
|
admin_id: int,
|
||||||
|
users: Optional[List[User]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Рассылка сообщений пользователям через Redis очередь
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bot: Инстанс бота
|
||||||
|
message: Сообщение для рассылки
|
||||||
|
admin_id: ID администратора, который запустил рассылку
|
||||||
|
users: Список пользователей (если None - всем зарегистрированным)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: Статистика рассылки
|
||||||
|
"""
|
||||||
|
# Создаем лог рассылки
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
broadcast_log = BroadcastLog(
|
||||||
|
broadcast_type='direct',
|
||||||
|
message_type=message.content_type,
|
||||||
|
message_text=message.text or message.caption,
|
||||||
|
file_id=self._get_file_id(message),
|
||||||
|
created_by=admin_id,
|
||||||
|
status='in_progress'
|
||||||
|
)
|
||||||
|
session.add(broadcast_log)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(broadcast_log)
|
||||||
|
log_id = broadcast_log.id
|
||||||
|
|
||||||
|
# Получаем список пользователей
|
||||||
|
if users is None:
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
# Получаем всех зарегистрированных пользователей
|
||||||
|
stmt = select(User).where(User.is_registered == True)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
all_users = result.scalars().all()
|
||||||
|
|
||||||
|
# Получаем список заблокированных пользователей
|
||||||
|
blocked_stmt = select(BlockedUser.telegram_id).where(
|
||||||
|
BlockedUser.is_active == True
|
||||||
|
)
|
||||||
|
blocked_result = await session.execute(blocked_stmt)
|
||||||
|
blocked_ids = set(row[0] for row in blocked_result.fetchall())
|
||||||
|
|
||||||
|
# Фильтруем пользователей, исключая заблокированных
|
||||||
|
users = [u for u in all_users if u.telegram_id not in blocked_ids]
|
||||||
|
|
||||||
|
total_users = len(users)
|
||||||
|
success_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
blocked_count = 0
|
||||||
|
|
||||||
|
# Рассылаем пакетами
|
||||||
|
for i in range(0, total_users, self.BATCH_SIZE):
|
||||||
|
batch = users[i:i + self.BATCH_SIZE]
|
||||||
|
|
||||||
|
# Отправляем пакет
|
||||||
|
tasks = []
|
||||||
|
for user in batch:
|
||||||
|
tasks.append(self.send_message_to_user(bot, user, message))
|
||||||
|
|
||||||
|
# Ждем завершения пакета
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
# Подсчитываем результаты
|
||||||
|
for result in results:
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
failed_count += 1
|
||||||
|
elif result[0]: # success
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
failed_count += 1
|
||||||
|
if result[1] in ['blocked_bot', 'deactivated', 'not_found']:
|
||||||
|
blocked_count += 1
|
||||||
|
|
||||||
|
# Задержка между пакетами
|
||||||
|
if i + self.BATCH_SIZE < total_users:
|
||||||
|
await asyncio.sleep(self.BATCH_DELAY)
|
||||||
|
|
||||||
|
# Обновляем лог
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
stmt = select(BroadcastLog).where(BroadcastLog.id == log_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
broadcast_log = result.scalar_one()
|
||||||
|
|
||||||
|
broadcast_log.total_recipients = total_users
|
||||||
|
broadcast_log.success_count = success_count
|
||||||
|
broadcast_log.failed_count = failed_count
|
||||||
|
broadcast_log.blocked_count = blocked_count
|
||||||
|
broadcast_log.completed_at = datetime.now(timezone.utc)
|
||||||
|
broadcast_log.status = 'completed'
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total': total_users,
|
||||||
|
'success': success_count,
|
||||||
|
'failed': failed_count,
|
||||||
|
'blocked': blocked_count
|
||||||
|
}
|
||||||
|
|
||||||
|
async def broadcast_to_channel(
|
||||||
|
self,
|
||||||
|
bot: Bot,
|
||||||
|
message: Message,
|
||||||
|
channel_id: int,
|
||||||
|
admin_id: int
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Отправка сообщения в канал
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bot: Инстанс бота
|
||||||
|
message: Сообщение для отправки
|
||||||
|
channel_id: ID канала
|
||||||
|
admin_id: ID администратора
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: Успех операции
|
||||||
|
"""
|
||||||
|
# Создаем лог
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
broadcast_log = BroadcastLog(
|
||||||
|
broadcast_type='channel',
|
||||||
|
target_id=channel_id,
|
||||||
|
message_type=message.content_type,
|
||||||
|
message_text=message.text or message.caption,
|
||||||
|
file_id=self._get_file_id(message),
|
||||||
|
created_by=admin_id,
|
||||||
|
total_recipients=1,
|
||||||
|
status='in_progress'
|
||||||
|
)
|
||||||
|
session.add(broadcast_log)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(broadcast_log)
|
||||||
|
log_id = broadcast_log.id
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Отправляем в канал
|
||||||
|
if message.text:
|
||||||
|
await bot.send_message(channel_id, message.text, parse_mode="Markdown")
|
||||||
|
elif message.photo:
|
||||||
|
await bot.send_photo(
|
||||||
|
channel_id,
|
||||||
|
photo=message.photo[-1].file_id,
|
||||||
|
caption=message.caption,
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
elif message.video:
|
||||||
|
await bot.send_video(
|
||||||
|
channel_id,
|
||||||
|
video=message.video.file_id,
|
||||||
|
caption=message.caption,
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
elif message.document:
|
||||||
|
await bot.send_document(
|
||||||
|
channel_id,
|
||||||
|
document=message.document.file_id,
|
||||||
|
caption=message.caption,
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await message.copy_to(channel_id)
|
||||||
|
|
||||||
|
# Обновляем лог
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
stmt = select(BroadcastLog).where(BroadcastLog.id == log_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
broadcast_log = result.scalar_one()
|
||||||
|
|
||||||
|
broadcast_log.success_count = 1
|
||||||
|
broadcast_log.completed_at = datetime.now(timezone.utc)
|
||||||
|
broadcast_log.status = 'completed'
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка отправки в канал {channel_id}: {e}")
|
||||||
|
|
||||||
|
# Обновляем лог
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
stmt = select(BroadcastLog).where(BroadcastLog.id == log_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
broadcast_log = result.scalar_one()
|
||||||
|
|
||||||
|
broadcast_log.failed_count = 1
|
||||||
|
broadcast_log.completed_at = datetime.now(timezone.utc)
|
||||||
|
broadcast_log.status = 'failed'
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _get_file_id(self, message: Message) -> Optional[str]:
|
||||||
|
"""Получить file_id из сообщения"""
|
||||||
|
if message.photo:
|
||||||
|
return message.photo[-1].file_id
|
||||||
|
elif message.video:
|
||||||
|
return message.video.file_id
|
||||||
|
elif message.document:
|
||||||
|
return message.document.file_id
|
||||||
|
elif message.animation:
|
||||||
|
return message.animation.file_id
|
||||||
|
elif message.voice:
|
||||||
|
return message.voice.file_id
|
||||||
|
elif message.audio:
|
||||||
|
return message.audio.file_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Глобальный экземпляр сервиса
|
||||||
|
broadcast_service = BroadcastService()
|
||||||
@@ -360,7 +360,16 @@ class ChatPermissionService:
|
|||||||
if settings and settings.global_ban:
|
if settings and settings.global_ban:
|
||||||
return False, "Чат временно закрыт администратором"
|
return False, "Чат временно закрыт администратором"
|
||||||
|
|
||||||
# Проверяем личный бан
|
# Проверяем is_chat_banned в модели User
|
||||||
|
from .models import User
|
||||||
|
stmt = select(User).where(User.telegram_id == telegram_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if user and user.is_chat_banned:
|
||||||
|
return False, "Вы заблокированы и не можете отправлять сообщения в чат"
|
||||||
|
|
||||||
|
# Проверяем личный бан (старая система через BannedUser)
|
||||||
is_banned = await BanService.is_banned(session, telegram_id)
|
is_banned = await BanService.is_banned(session, telegram_id)
|
||||||
if is_banned:
|
if is_banned:
|
||||||
return False, "Вы заблокированы и не можете отправлять сообщения"
|
return False, "Вы заблокированы и не можете отправлять сообщения"
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ if not BOT_TOKEN:
|
|||||||
# База данных
|
# База данных
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db")
|
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./lottery_bot.db")
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
|
||||||
# Администраторы
|
# Администраторы
|
||||||
ADMIN_IDS = []
|
ADMIN_IDS = []
|
||||||
admin_ids_str = os.getenv("ADMIN_IDS", "")
|
admin_ids_str = os.getenv("ADMIN_IDS", "")
|
||||||
|
|||||||
221
src/core/emoji_mapping_service.py
Normal file
221
src/core/emoji_mapping_service.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
"""Сервис для управления маппингом кастомных эмодзи"""
|
||||||
|
from typing import Optional, List, Dict
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import re
|
||||||
|
|
||||||
|
from src.core.models import EmojiMapping, User
|
||||||
|
|
||||||
|
|
||||||
|
class EmojiMappingService:
|
||||||
|
"""Служба для управления маппингом эмодзи и их ID"""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession):
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def register_emoji(
|
||||||
|
self,
|
||||||
|
emoji_text: str,
|
||||||
|
emoji_id: str,
|
||||||
|
admin_id: int,
|
||||||
|
description: Optional[str] = None
|
||||||
|
) -> EmojiMapping:
|
||||||
|
"""
|
||||||
|
Зарегистрировать новый эмодзи с его ID от Telegram
|
||||||
|
|
||||||
|
Args:
|
||||||
|
emoji_text: Сам эмодзи символ (например, '🎲')
|
||||||
|
emoji_id: telegram_emoji_id от Telegram API
|
||||||
|
admin_id: ID админа, который добавил эмодзи
|
||||||
|
description: Описание назначения этого эмодзи
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Созданный объект EmojiMapping
|
||||||
|
"""
|
||||||
|
emoji = EmojiMapping(
|
||||||
|
emoji_text=emoji_text,
|
||||||
|
emoji_id=emoji_id,
|
||||||
|
admin_id=admin_id,
|
||||||
|
description=description,
|
||||||
|
created_at=datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
self.session.add(emoji)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(emoji)
|
||||||
|
return emoji
|
||||||
|
|
||||||
|
async def get_emoji_by_text(self, emoji_text: str, admin_id: Optional[int] = None) -> Optional[EmojiMapping]:
|
||||||
|
"""
|
||||||
|
Получить маппинг эмодзи по его текстовому значению
|
||||||
|
|
||||||
|
Args:
|
||||||
|
emoji_text: Текст эмодзи
|
||||||
|
admin_id: Опционально - ID админа для фильтрации
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
EmojiMapping объект или None
|
||||||
|
"""
|
||||||
|
query = select(EmojiMapping).where(EmojiMapping.emoji_text == emoji_text)
|
||||||
|
if admin_id:
|
||||||
|
query = query.where(EmojiMapping.admin_id == admin_id)
|
||||||
|
|
||||||
|
result = await self.session.execute(query)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
async def get_emoji_by_id(self, emoji_id: str) -> Optional[EmojiMapping]:
|
||||||
|
"""
|
||||||
|
Получить маппинг эмодзи по его emoji_id
|
||||||
|
|
||||||
|
Args:
|
||||||
|
emoji_id: telegram_emoji_id
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
EmojiMapping объект или None
|
||||||
|
"""
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(EmojiMapping).where(EmojiMapping.emoji_id == emoji_id)
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
async def get_all_emoji_by_admin(self, admin_id: int) -> List[EmojiMapping]:
|
||||||
|
"""
|
||||||
|
Получить все эмодзи, добавленные конкретным админом
|
||||||
|
|
||||||
|
Args:
|
||||||
|
admin_id: ID админа
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список EmojiMapping объектов
|
||||||
|
"""
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(EmojiMapping).where(EmojiMapping.admin_id == admin_id)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_all_emojis(self) -> List[EmojiMapping]:
|
||||||
|
"""Получить все зарегистрированные эмодзи"""
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(EmojiMapping).order_by(EmojiMapping.created_at.desc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def delete_emoji(self, emoji_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Удалить эмодзи маппинг
|
||||||
|
|
||||||
|
Args:
|
||||||
|
emoji_id: telegram_emoji_id
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если удален, False если не найден
|
||||||
|
"""
|
||||||
|
emoji = await self.get_emoji_by_id(emoji_id)
|
||||||
|
if emoji:
|
||||||
|
await self.session.delete(emoji)
|
||||||
|
await self.session.commit()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def update_last_used(self, emoji_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Обновить время последнего использования эмодзи
|
||||||
|
|
||||||
|
Args:
|
||||||
|
emoji_id: telegram_emoji_id
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если обновлен, False если не найден
|
||||||
|
"""
|
||||||
|
await self.session.execute(
|
||||||
|
update(EmojiMapping)
|
||||||
|
.where(EmojiMapping.emoji_id == emoji_id)
|
||||||
|
.values(last_used_at=datetime.now(timezone.utc))
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def replace_emojis_in_text(self, text: str) -> str:
|
||||||
|
"""
|
||||||
|
Заменить все известные эмодзи на их emoji_id в тексте
|
||||||
|
|
||||||
|
Это используется перед отправкой сообщения в Telegram,
|
||||||
|
чтобы эмодзи выглядели так же, как их отправил админ
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Исходный текст с эмодзи
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Текст с заменой эмодзи на emoji_id
|
||||||
|
"""
|
||||||
|
# Получаем все эмодзи маппинги
|
||||||
|
emojis = await self.get_all_emojis()
|
||||||
|
|
||||||
|
# Заменяем каждый эмодзи на его emoji_id
|
||||||
|
for emoji in emojis:
|
||||||
|
# Экранируем специальные символы если нужно
|
||||||
|
if emoji.emoji_text in text:
|
||||||
|
# Замена с сохранением контекста - оборачиваем в специальные маркеры
|
||||||
|
# Это позволит потом распознать что это эмодзи ID а не обычный текст
|
||||||
|
text = text.replace(emoji.emoji_text, f"|{emoji.emoji_id}|")
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
async def restore_emojis_in_text(self, text: str) -> str:
|
||||||
|
"""
|
||||||
|
Восстановить эмодзи из их emoji_id в тексте (обратная операция)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст с emoji_id маркерами (|emoji_id|)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Текст с восстановленными эмодзи
|
||||||
|
"""
|
||||||
|
# Получаем все эмодзи маппинги
|
||||||
|
emojis = await self.get_all_emojis()
|
||||||
|
|
||||||
|
# Восстанавливаем каждый эмодзи из его ID
|
||||||
|
for emoji in emojis:
|
||||||
|
if f"|{emoji.emoji_id}|" in text:
|
||||||
|
text = text.replace(f"|{emoji.emoji_id}|", emoji.emoji_text)
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
async def get_emoji_mapping_dict(self) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Получить словарь маппинга эмодзи -> emoji_id для быстрого доступа
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Словарь {emoji_text: emoji_id}
|
||||||
|
"""
|
||||||
|
emojis = await self.get_all_emojis()
|
||||||
|
return {emoji.emoji_text: emoji.emoji_id for emoji in emojis}
|
||||||
|
|
||||||
|
async def bulk_register_emojis(self, emojis_data: List[Dict]) -> List[EmojiMapping]:
|
||||||
|
"""
|
||||||
|
Зарегистрировать несколько эмодзи сразу
|
||||||
|
|
||||||
|
Args:
|
||||||
|
emojis_data: Список со структурой [
|
||||||
|
{
|
||||||
|
'emoji_text': '🎲',
|
||||||
|
'emoji_id': 'some_id',
|
||||||
|
'admin_id': 123,
|
||||||
|
'description': 'Для лотереи'
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список созданных EmojiMapping объектов
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
for emoji_data in emojis_data:
|
||||||
|
emoji = await self.register_emoji(
|
||||||
|
emoji_text=emoji_data['emoji_text'],
|
||||||
|
emoji_id=emoji_data['emoji_id'],
|
||||||
|
admin_id=emoji_data['admin_id'],
|
||||||
|
description=emoji_data.get('description')
|
||||||
|
)
|
||||||
|
result.append(emoji)
|
||||||
|
return result
|
||||||
61
src/core/emoji_message_helper.py
Normal file
61
src/core/emoji_message_helper.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""
|
||||||
|
Утилиты для автоматической замены эмодзи на emoji_id при отправке сообщений
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
from aiogram.types import Message, CallbackQuery
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from .emoji_mapping_service import EmojiMappingService
|
||||||
|
|
||||||
|
|
||||||
|
class EmojiMessageHelper:
|
||||||
|
"""Помощник для работы с эмодзи в сообщениях"""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession):
|
||||||
|
self.service = EmojiMappingService(session)
|
||||||
|
|
||||||
|
async def process_text_before_send(self, text: str) -> str:
|
||||||
|
"""
|
||||||
|
Обработать текст перед отправкой - заменить эмодзи на их ID
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст сообщения
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Обработанный текст с заменой эмодзи на ID
|
||||||
|
"""
|
||||||
|
return await self.service.replace_emojis_in_text(text)
|
||||||
|
|
||||||
|
async def process_text_after_receive(self, text: str) -> str:
|
||||||
|
"""
|
||||||
|
Обработать текст после получения - восстановить эмодзи из ID
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст с ID эмодзи
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Текст с восстановленными эмодзи
|
||||||
|
"""
|
||||||
|
return await self.service.restore_emojis_in_text(text)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_emoji_aware_text(session: AsyncSession, text: str) -> str:
|
||||||
|
"""
|
||||||
|
Удобная функция для получения эмодзи-оптимизированного текста
|
||||||
|
|
||||||
|
Заменяет все известные эмодзи на их telegram_emoji_id для правильного отображения
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Сессия БД
|
||||||
|
text: Исходный текст
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Текст с замененными эмодзи на их ID
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> text = "🎲 Выиграли! 🏆"
|
||||||
|
>>> processed = await get_emoji_aware_text(session, text)
|
||||||
|
>>> await message.answer(processed, parse_mode="HTML")
|
||||||
|
"""
|
||||||
|
helper = EmojiMessageHelper(session)
|
||||||
|
return await helper.process_text_before_send(text)
|
||||||
@@ -14,11 +14,14 @@ class User(Base):
|
|||||||
username = Column(String(255))
|
username = Column(String(255))
|
||||||
first_name = Column(String(255))
|
first_name = Column(String(255))
|
||||||
last_name = Column(String(255))
|
last_name = Column(String(255))
|
||||||
|
nickname = Column(String(100), nullable=True) # Никнейм пользователя для чата
|
||||||
phone = Column(String(20), nullable=True) # Телефон для верификации
|
phone = Column(String(20), nullable=True) # Телефон для верификации
|
||||||
club_card_number = Column(String(50), unique=True, nullable=True, index=True) # Номер клубной карты
|
club_card_number = Column(String(50), unique=True, nullable=True, index=True) # Номер клубной карты
|
||||||
is_registered = Column(Boolean, default=False) # Прошел ли полную регистрацию
|
is_registered = Column(Boolean, default=False) # Прошел ли полную регистрацию
|
||||||
is_admin = Column(Boolean, default=False)
|
is_admin = Column(Boolean, default=False)
|
||||||
|
is_chat_banned = Column(Boolean, default=False) # Заблокирован ли в чате бота
|
||||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
last_activity = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) # Последняя активность
|
||||||
|
|
||||||
# Секретный код для верификации выигрыша (генерируется при регистрации)
|
# Секретный код для верификации выигрыша (генерируется при регистрации)
|
||||||
verification_code = Column(String(10), unique=True, nullable=True)
|
verification_code = Column(String(10), unique=True, nullable=True)
|
||||||
@@ -241,4 +244,94 @@ class P2PMessage(Base):
|
|||||||
reply_to = relationship("P2PMessage", remote_side=[id], backref="replies")
|
reply_to = relationship("P2PMessage", remote_side=[id], backref="replies")
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<P2PMessage(id={self.id}, from={self.sender_id}, to={self.recipient_id})>"
|
return f"<P2PMessage(id={self.id}, from={self.sender_id}, to={self.recipient_id})>"
|
||||||
|
|
||||||
|
class BroadcastChannel(Base):
|
||||||
|
"""Каналы и группы для рассылки"""
|
||||||
|
__tablename__ = "broadcast_channels"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
chat_id = Column(BigInteger, nullable=False, unique=True, index=True) # ID канала или группы
|
||||||
|
chat_type = Column(String(20), nullable=False) # 'channel' или 'group'
|
||||||
|
title = Column(String(255), nullable=False) # Название
|
||||||
|
username = Column(String(255), nullable=True) # Username (если есть)
|
||||||
|
description = Column(Text, nullable=True) # Описание
|
||||||
|
is_active = Column(Boolean, default=True, index=True) # Активен ли для рассылок
|
||||||
|
added_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
# Связи
|
||||||
|
admin = relationship("User")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<BroadcastChannel(id={self.id}, title={self.title}, type={self.chat_type})>"
|
||||||
|
|
||||||
|
|
||||||
|
class BlockedUser(Base):
|
||||||
|
"""Пользователи, которые заблокировали бота или недоступны"""
|
||||||
|
__tablename__ = "blocked_users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
telegram_id = Column(BigInteger, nullable=False, unique=True, index=True)
|
||||||
|
error_type = Column(String(100), nullable=False) # тип ошибки (blocked, deleted, deactivated, etc.)
|
||||||
|
error_message = Column(Text, nullable=True) # Полное сообщение об ошибке
|
||||||
|
first_blocked_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
last_attempt_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
attempt_count = Column(Integer, default=1) # Количество неудачных попыток
|
||||||
|
is_active = Column(Boolean, default=True, index=True) # Активна ли блокировка
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<BlockedUser(telegram_id={self.telegram_id}, error={self.error_type})>"
|
||||||
|
|
||||||
|
|
||||||
|
class BroadcastLog(Base):
|
||||||
|
"""История рассылок"""
|
||||||
|
__tablename__ = "broadcast_logs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
broadcast_type = Column(String(20), nullable=False, index=True) # 'direct', 'channel', 'group'
|
||||||
|
target_id = Column(BigInteger, nullable=True) # ID канала/группы (null для direct)
|
||||||
|
message_type = Column(String(20), nullable=False) # text, photo, video, etc.
|
||||||
|
message_text = Column(Text, nullable=True) # Текст сообщения
|
||||||
|
file_id = Column(String(255), nullable=True) # ID файла (если есть)
|
||||||
|
|
||||||
|
# Статистика
|
||||||
|
total_recipients = Column(Integer, default=0) # Всего получателей
|
||||||
|
success_count = Column(Integer, default=0) # Успешно доставлено
|
||||||
|
failed_count = Column(Integer, default=0) # Не доставлено
|
||||||
|
blocked_count = Column(Integer, default=0) # Заблокировали бота
|
||||||
|
|
||||||
|
# Метаданные
|
||||||
|
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
started_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
status = Column(String(20), default='pending', index=True) # pending, in_progress, completed, failed
|
||||||
|
|
||||||
|
# Связи
|
||||||
|
admin = relationship("User")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<BroadcastLog(id={self.id}, type={self.broadcast_type}, status={self.status})>"
|
||||||
|
|
||||||
|
|
||||||
|
class EmojiMapping(Base):
|
||||||
|
"""Маппинг эмодзи на их telegram_emoji_id для безопасной передачи в чат"""
|
||||||
|
__tablename__ = "emoji_mappings"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
emoji_text = Column(String(10), nullable=False, index=True) # Сам эмодзи (например, 🎲)
|
||||||
|
emoji_id = Column(String(255), nullable=False, unique=True, index=True) # telegram_emoji_id из API
|
||||||
|
admin_id = Column(Integer, ForeignKey("users.id"), nullable=False) # Кто добавил
|
||||||
|
description = Column(String(255), nullable=True) # Описание назначения эмодзи
|
||||||
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
last_used_at = Column(DateTime(timezone=True), nullable=True) # Последнее использование
|
||||||
|
|
||||||
|
# Связи
|
||||||
|
admin = relationship("User")
|
||||||
|
|
||||||
|
# Уникальность: один эмодзи от админа не может быть добавлен дважды
|
||||||
|
__table_args__ = (UniqueConstraint('emoji_text', 'admin_id', name='unique_emoji_per_admin'),)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<EmojiMapping(emoji={self.emoji_text}, emoji_id={self.emoji_id[:20]}...)>"
|
||||||
|
|||||||
93
src/core/premium_emoji.py
Normal file
93
src/core/premium_emoji.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""
|
||||||
|
Поддержка премиум эмодзи для ботов, созданных с премиум аккаунтов
|
||||||
|
Telegram Bot API поддерживает премиум эмодзи начиная с версии 7.0
|
||||||
|
|
||||||
|
Для использования премиум эмодзи:
|
||||||
|
1. Бот должен быть создан с премиум аккаунта
|
||||||
|
2. Использовать эмодзи напрямую в тексте сообщений
|
||||||
|
3. Использовать parse_mode="HTML" или parse_mode="Markdown"
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from aiogram.types import MessageEntity, TextQuote
|
||||||
|
from aiogram.enums import MessageEntityType
|
||||||
|
|
||||||
|
|
||||||
|
class PremiumEmojiConfig:
|
||||||
|
"""Конфигурация поддержки премиум эмодзи"""
|
||||||
|
|
||||||
|
# Флаг, что бот может использовать премиум эмодзи
|
||||||
|
SUPPORTS_PREMIUM_EMOJI = True
|
||||||
|
|
||||||
|
# Стандартные parse_mode для автоматической поддержки эмодзи
|
||||||
|
DEFAULT_PARSE_MODE = "HTML" # Поддерживает эмодзи лучше чем Markdown
|
||||||
|
|
||||||
|
# Премиум эмодзи которые используются в приложении
|
||||||
|
PREMIUM_EMOJIS = {
|
||||||
|
# Розыгрыши
|
||||||
|
"🎲_premium": "🎲", # Если есть премиум версия
|
||||||
|
"🏆_premium": "🏆",
|
||||||
|
"🎯_premium": "🎯",
|
||||||
|
|
||||||
|
# Логины
|
||||||
|
"📱_premium": "📱",
|
||||||
|
"🔐_premium": "🔐",
|
||||||
|
|
||||||
|
# Статусы
|
||||||
|
"✅_premium": "✅",
|
||||||
|
"❌_premium": "❌",
|
||||||
|
"⏸_premium": "⏸️",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def supports_premium_emoji() -> bool:
|
||||||
|
"""Проверить поддерживает ли бот премиум эмодзи"""
|
||||||
|
return PremiumEmojiConfig.SUPPORTS_PREMIUM_EMOJI
|
||||||
|
|
||||||
|
|
||||||
|
def get_parse_mode() -> str:
|
||||||
|
"""Получить оптимальный parse_mode для поддержки эмодзи"""
|
||||||
|
return PremiumEmojiConfig.DEFAULT_PARSE_MODE
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_emoji_support(text: str) -> str:
|
||||||
|
"""
|
||||||
|
Убедиться что текст может быть отправлен с эмодзи
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст сообщения
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Обработанный текст с поддержкой эмодзи
|
||||||
|
"""
|
||||||
|
# В Aiogram 3.16+ эмодзи автоматически поддерживаются при правильном parse_mode
|
||||||
|
# Эта функция может быть расширена для дополнительной обработки если нужно
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
async def send_message_with_emoji(
|
||||||
|
send_func,
|
||||||
|
text: str,
|
||||||
|
parse_mode: Optional[str] = None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Отправить сообщение с поддержкой премиум эмодзи
|
||||||
|
|
||||||
|
Args:
|
||||||
|
send_func: Функция отправки (message.answer, callback.message.edit_text и т.д.)
|
||||||
|
text: Текст сообщения
|
||||||
|
parse_mode: Parse mode (если None, использует default)
|
||||||
|
**kwargs: Дополнительные параметры
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Результат отправки сообщения
|
||||||
|
"""
|
||||||
|
if parse_mode is None:
|
||||||
|
parse_mode = get_parse_mode()
|
||||||
|
|
||||||
|
# Убедиться что текст может содержать эмодзи
|
||||||
|
text = ensure_emoji_support(text)
|
||||||
|
|
||||||
|
# Отправить сообщение
|
||||||
|
return await send_func(text, parse_mode=parse_mode, **kwargs)
|
||||||
56
src/core/scheduler.py
Normal file
56
src/core/scheduler.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""
|
||||||
|
Планировщик фоновых задач для бота
|
||||||
|
"""
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from src.core.activity_service import ActivityService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BotScheduler:
|
||||||
|
"""Планировщик задач для бота"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.scheduler = AsyncIOScheduler()
|
||||||
|
|
||||||
|
def setup_jobs(self):
|
||||||
|
"""Настройка всех периодических задач"""
|
||||||
|
|
||||||
|
# Проверка неактивных пользователей каждый день в 03:00
|
||||||
|
self.scheduler.add_job(
|
||||||
|
self._check_inactive_users,
|
||||||
|
trigger=CronTrigger(hour=3, minute=0),
|
||||||
|
id='check_inactive_users',
|
||||||
|
name='Проверка неактивных пользователей',
|
||||||
|
replace_existing=True
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Планировщик задач настроен")
|
||||||
|
|
||||||
|
async def _check_inactive_users(self):
|
||||||
|
"""Проверка и блокировка неактивных пользователей"""
|
||||||
|
try:
|
||||||
|
logger.info("Запуск проверки неактивных пользователей")
|
||||||
|
marked = await ActivityService.check_and_mark_inactive_users()
|
||||||
|
logger.info(f"Проверка завершена. Неактивных пользователей помечено: {marked}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при проверке неактивных пользователей: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Запуск планировщика"""
|
||||||
|
self.setup_jobs()
|
||||||
|
self.scheduler.start()
|
||||||
|
logger.info("Планировщик задач запущен")
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
"""Остановка планировщика"""
|
||||||
|
if self.scheduler.running:
|
||||||
|
self.scheduler.shutdown()
|
||||||
|
logger.info("Планировщик задач остановлен")
|
||||||
|
|
||||||
|
|
||||||
|
# Глобальный экземпляр планировщика
|
||||||
|
bot_scheduler = BotScheduler()
|
||||||
@@ -13,7 +13,7 @@ class UserService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_or_create_user(session: AsyncSession, telegram_id: int,
|
async def get_or_create_user(session: AsyncSession, telegram_id: int,
|
||||||
username: str = None, first_name: str = None,
|
username: str = None, first_name: str = None,
|
||||||
last_name: str = None) -> User:
|
last_name: str = None, nickname: str = None) -> User:
|
||||||
"""Получить или создать пользователя"""
|
"""Получить или создать пользователя"""
|
||||||
# Пробуем найти существующего пользователя
|
# Пробуем найти существующего пользователя
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
@@ -26,6 +26,9 @@ class UserService:
|
|||||||
user.username = username
|
user.username = username
|
||||||
user.first_name = first_name
|
user.first_name = first_name
|
||||||
user.last_name = last_name
|
user.last_name = last_name
|
||||||
|
# Обновляем nickname только если он передан
|
||||||
|
if nickname is not None:
|
||||||
|
user.nickname = nickname
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@@ -34,7 +37,8 @@ class UserService:
|
|||||||
telegram_id=telegram_id,
|
telegram_id=telegram_id,
|
||||||
username=username,
|
username=username,
|
||||||
first_name=first_name,
|
first_name=first_name,
|
||||||
last_name=last_name
|
last_name=last_name,
|
||||||
|
nickname=nickname
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
42
src/core/telegram_config.py
Normal file
42
src/core/telegram_config.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
Глобальная конфигурация для Telegram Bot API параметров
|
||||||
|
Включая поддержку премиум эмодзи
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Parse mode для всех сообщений
|
||||||
|
# HTML поддерживает премиум эмодзи лучше чем Markdown
|
||||||
|
GLOBAL_PARSE_MODE = "HTML"
|
||||||
|
|
||||||
|
# Доступные parse modes
|
||||||
|
PARSE_MODES = {
|
||||||
|
"HTML": "HTML",
|
||||||
|
"MARKDOWN": "Markdown",
|
||||||
|
"NONE": None
|
||||||
|
}
|
||||||
|
|
||||||
|
# Какой parse mode использовать для разных типов сообщений
|
||||||
|
MESSAGE_PARSE_MODES = {
|
||||||
|
"text_message": "HTML", # Обычные текстовые сообщения
|
||||||
|
"inline_keyboard": "HTML", # С inline клавиатурой
|
||||||
|
"reply_keyboard": "HTML", # С reply клавиатуре
|
||||||
|
"edit_message": "HTML", # Редактирование сообщения
|
||||||
|
"broadcast": "HTML", # Массовые рассылки
|
||||||
|
"admin_broadcast": "HTML", # Административные рассылки
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_parse_mode(message_type: str = "text_message") -> str:
|
||||||
|
"""
|
||||||
|
Получить parse_mode для типа сообщения
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_type: Тип сообщения (см. MESSAGE_PARSE_MODES)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parse mode строка ("HTML", "Markdown", None)
|
||||||
|
"""
|
||||||
|
return MESSAGE_PARSE_MODES.get(message_type, GLOBAL_PARSE_MODE)
|
||||||
|
|
||||||
|
|
||||||
|
def get_global_parse_mode() -> str:
|
||||||
|
"""Получить глобальный parse mode"""
|
||||||
|
return GLOBAL_PARSE_MODE
|
||||||
257
src/core/user_management.py
Normal file
257
src/core/user_management.py
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
"""
|
||||||
|
Сервис управления пользователями с поиском и пагинацией
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import select, or_, func, and_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from typing import List, Dict, Any, Optional, Tuple
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .models import User
|
||||||
|
from .database import async_session_maker
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class UserManagementService:
|
||||||
|
"""Сервис для управления пользователями"""
|
||||||
|
|
||||||
|
# Количество пользователей на странице
|
||||||
|
USERS_PER_PAGE = 15
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def search_users(
|
||||||
|
session: AsyncSession,
|
||||||
|
query: str = None,
|
||||||
|
page: int = 1,
|
||||||
|
per_page: int = None,
|
||||||
|
filters: Dict[str, Any] = None
|
||||||
|
) -> Tuple[List[User], int]:
|
||||||
|
"""
|
||||||
|
Поиск пользователей с фильтрацией и пагинацией
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Сессия БД
|
||||||
|
query: Поисковый запрос (ищет по username, имени, telegram_id, номеру карты)
|
||||||
|
page: Номер страницы (начиная с 1)
|
||||||
|
per_page: Количество на странице (по умолчанию USERS_PER_PAGE)
|
||||||
|
filters: Дополнительные фильтры:
|
||||||
|
- is_registered: bool
|
||||||
|
- is_admin: bool
|
||||||
|
- is_chat_banned: bool
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple[List[User], int]: Список пользователей и общее количество
|
||||||
|
"""
|
||||||
|
if per_page is None:
|
||||||
|
per_page = UserManagementService.USERS_PER_PAGE
|
||||||
|
|
||||||
|
# Базовый запрос
|
||||||
|
stmt = select(User)
|
||||||
|
conditions = []
|
||||||
|
|
||||||
|
# Поисковый запрос
|
||||||
|
if query and query.strip():
|
||||||
|
query = query.strip()
|
||||||
|
search_conditions = []
|
||||||
|
|
||||||
|
# Поиск по username
|
||||||
|
if query.startswith('@'):
|
||||||
|
search_conditions.append(User.username.ilike(f'%{query[1:]}%'))
|
||||||
|
else:
|
||||||
|
# Поиск по всем полям
|
||||||
|
search_conditions.append(User.username.ilike(f'%{query}%'))
|
||||||
|
search_conditions.append(User.first_name.ilike(f'%{query}%'))
|
||||||
|
search_conditions.append(User.last_name.ilike(f'%{query}%'))
|
||||||
|
search_conditions.append(User.nickname.ilike(f'%{query}%'))
|
||||||
|
search_conditions.append(User.club_card_number.ilike(f'%{query}%'))
|
||||||
|
|
||||||
|
# Если запрос - число, ищем по telegram_id
|
||||||
|
if query.isdigit():
|
||||||
|
search_conditions.append(User.telegram_id == int(query))
|
||||||
|
|
||||||
|
conditions.append(or_(*search_conditions))
|
||||||
|
|
||||||
|
# Применяем фильтры
|
||||||
|
if filters:
|
||||||
|
if 'is_registered' in filters:
|
||||||
|
conditions.append(User.is_registered == filters['is_registered'])
|
||||||
|
if 'is_admin' in filters:
|
||||||
|
conditions.append(User.is_admin == filters['is_admin'])
|
||||||
|
if 'is_chat_banned' in filters:
|
||||||
|
conditions.append(User.is_chat_banned == filters['is_chat_banned'])
|
||||||
|
|
||||||
|
# Добавляем условия к запросу
|
||||||
|
if conditions:
|
||||||
|
stmt = stmt.where(and_(*conditions))
|
||||||
|
|
||||||
|
# Получаем общее количество
|
||||||
|
count_stmt = select(func.count()).select_from(stmt.subquery())
|
||||||
|
total_result = await session.execute(count_stmt)
|
||||||
|
total = total_result.scalar()
|
||||||
|
|
||||||
|
# Применяем сортировку и пагинацию
|
||||||
|
stmt = stmt.order_by(User.created_at.desc())
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
stmt = stmt.limit(per_page).offset(offset)
|
||||||
|
|
||||||
|
# Выполняем запрос
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
users = list(result.scalars().all())
|
||||||
|
|
||||||
|
return users, total
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
|
||||||
|
"""Получить пользователя по ID"""
|
||||||
|
stmt = select(User).where(User.id == user_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_user_by_telegram_id(session: AsyncSession, telegram_id: int) -> Optional[User]:
|
||||||
|
"""Получить пользователя по Telegram ID"""
|
||||||
|
stmt = select(User).where(User.telegram_id == telegram_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def ban_user_in_chat(session: AsyncSession, user_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Заблокировать пользователя в чате
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Сессия БД
|
||||||
|
user_id: ID пользователя
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: Успех операции
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = await UserManagementService.get_user_by_id(session, user_id)
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
|
||||||
|
user.is_chat_banned = True
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"Пользователь {user.telegram_id} заблокирован в чате")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка блокировки пользователя {user_id} в чате: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def unban_user_in_chat(session: AsyncSession, user_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Разблокировать пользователя в чате
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Сессия БД
|
||||||
|
user_id: ID пользователя
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: Успех операции
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = await UserManagementService.get_user_by_id(session, user_id)
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
|
||||||
|
user.is_chat_banned = False
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"Пользователь {user.telegram_id} разблокирован в чате")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка разблокировки пользователя {user_id} в чате: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_user_stats(session: AsyncSession) -> Dict[str, int]:
|
||||||
|
"""
|
||||||
|
Получить статистику по пользователям
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: Статистика
|
||||||
|
"""
|
||||||
|
# Общее количество
|
||||||
|
total_stmt = select(func.count(User.id))
|
||||||
|
total_result = await session.execute(total_stmt)
|
||||||
|
total = total_result.scalar()
|
||||||
|
|
||||||
|
# Зарегистрированные
|
||||||
|
registered_stmt = select(func.count(User.id)).where(User.is_registered == True)
|
||||||
|
registered_result = await session.execute(registered_stmt)
|
||||||
|
registered = registered_result.scalar()
|
||||||
|
|
||||||
|
# Админы
|
||||||
|
admin_stmt = select(func.count(User.id)).where(User.is_admin == True)
|
||||||
|
admin_result = await session.execute(admin_stmt)
|
||||||
|
admins = admin_result.scalar()
|
||||||
|
|
||||||
|
# Заблокированные в чате
|
||||||
|
banned_stmt = select(func.count(User.id)).where(User.is_chat_banned == True)
|
||||||
|
banned_result = await session.execute(banned_stmt)
|
||||||
|
banned = banned_result.scalar()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total': total,
|
||||||
|
'registered': registered,
|
||||||
|
'admins': admins,
|
||||||
|
'chat_banned': banned
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_user_info(user: User, detailed: bool = False) -> str:
|
||||||
|
"""
|
||||||
|
Форматировать информацию о пользователе для отображения
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: Пользователь
|
||||||
|
detailed: Детальная информация
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Форматированная информация
|
||||||
|
"""
|
||||||
|
# Базовая информация
|
||||||
|
info = f"👤 <b>{user.first_name}"
|
||||||
|
if user.last_name:
|
||||||
|
info += f" {user.last_name}"
|
||||||
|
info += "</b>"
|
||||||
|
|
||||||
|
if user.username:
|
||||||
|
info += f" (@{user.username})"
|
||||||
|
|
||||||
|
info += f"\n🆔 ID: <code>{user.telegram_id}</code>"
|
||||||
|
|
||||||
|
# Статусы
|
||||||
|
statuses = []
|
||||||
|
if user.is_admin:
|
||||||
|
statuses.append("👑 Админ")
|
||||||
|
if user.is_registered:
|
||||||
|
statuses.append("✅ Зарегистрирован")
|
||||||
|
if user.is_chat_banned:
|
||||||
|
statuses.append("🚫 Заблокирован в чате")
|
||||||
|
|
||||||
|
if statuses:
|
||||||
|
info += "\n" + " | ".join(statuses)
|
||||||
|
|
||||||
|
# Детальная информация
|
||||||
|
if detailed:
|
||||||
|
if user.nickname:
|
||||||
|
info += f"\n📝 Никнейм: {user.nickname}"
|
||||||
|
if user.club_card_number:
|
||||||
|
info += f"\n🎫 Клубная карта: <code>{user.club_card_number}</code>"
|
||||||
|
if user.phone:
|
||||||
|
info += f"\n📞 Телефон: <code>{user.phone}</code>"
|
||||||
|
|
||||||
|
# Даты
|
||||||
|
info += f"\n📅 Регистрация: {user.created_at.strftime('%d.%m.%Y %H:%M')}"
|
||||||
|
if user.last_activity:
|
||||||
|
days_inactive = (datetime.now(timezone.utc) - user.last_activity).days
|
||||||
|
info += f"\n⏰ Последняя активность: {user.last_activity.strftime('%d.%m.%Y %H:%M')}"
|
||||||
|
if days_inactive > 0:
|
||||||
|
info += f" ({days_inactive} дн. назад)"
|
||||||
|
|
||||||
|
return info
|
||||||
1
src/filters/__init__.py
Normal file
1
src/filters/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Кастомные фильтры для бота"""
|
||||||
28
src/filters/case_insensitive.py
Normal file
28
src/filters/case_insensitive.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""Регистронезависимый фильтр команд"""
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
|
||||||
|
class CaseInsensitiveCommand(Command):
|
||||||
|
"""
|
||||||
|
Регистронезависимый фильтр команд.
|
||||||
|
Обрабатывает команды независимо от регистра: /Start, /START, /start - все обрабатываются одинаково.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*commands: str,
|
||||||
|
prefix: str = "/",
|
||||||
|
ignore_mention: bool = False,
|
||||||
|
magic: Union[None, str] = None,
|
||||||
|
):
|
||||||
|
"""Инициализация с ignore_case=True для регистронезависимости"""
|
||||||
|
# Вызываем родительский конструктор с ignore_case=True
|
||||||
|
super().__init__(
|
||||||
|
*commands,
|
||||||
|
prefix=prefix,
|
||||||
|
ignore_case=True, # Включаем игнорирование регистра
|
||||||
|
ignore_mention=ignore_mention,
|
||||||
|
magic=magic
|
||||||
|
)
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ from aiogram.fsm.context import FSMContext
|
|||||||
from aiogram.fsm.state import State, StatesGroup
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
from sqlalchemy import select, and_
|
from sqlalchemy import select, and_
|
||||||
|
|
||||||
|
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||||
from src.core.database import async_session_maker
|
from src.core.database import async_session_maker
|
||||||
from src.core.registration_services import AccountService, WinnerNotificationService, RegistrationService
|
from src.core.registration_services import AccountService, WinnerNotificationService, RegistrationService
|
||||||
from src.core.services import UserService, LotteryService, ParticipationService
|
from src.core.services import UserService, LotteryService, ParticipationService
|
||||||
@@ -22,19 +23,19 @@ class AddAccountStates(StatesGroup):
|
|||||||
choosing_lottery = State()
|
choosing_lottery = State()
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("cancel"))
|
@router.message(CaseInsensitiveCommand("cancel"))
|
||||||
@admin_only
|
@admin_only
|
||||||
async def cancel_command(message: Message, state: FSMContext):
|
async def cancel_command(message: Message, state: FSMContext):
|
||||||
"""Отменить текущую операцию и сбросить состояние"""
|
"""Отменить текущую операцию и сбросить состояние (регистронезависимо)"""
|
||||||
await state.clear()
|
await state.clear()
|
||||||
await message.answer("✅ Состояние сброшено. Все операции отменены.")
|
await message.answer("✅ Состояние сброшено. Все операции отменены.")
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("add_account"))
|
@router.message(CaseInsensitiveCommand("add_account"))
|
||||||
@admin_only
|
@admin_only
|
||||||
async def add_account_command(message: Message, state: FSMContext):
|
async def add_account_command(message: Message, state: FSMContext):
|
||||||
"""
|
"""
|
||||||
Добавить счет пользователю по клубной карте
|
Добавить счет пользователю по клубной карте (регистронезависимо)
|
||||||
Формат: /add_account <club_card> <account_number>
|
Формат: /add_account <club_card> <account_number>
|
||||||
Или: /add_account (затем вводить данные построчно)
|
Или: /add_account (затем вводить данные построчно)
|
||||||
"""
|
"""
|
||||||
@@ -434,11 +435,11 @@ async def skip_lottery_add(callback: CallbackQuery, state: FSMContext):
|
|||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("remove_account"))
|
@router.message(CaseInsensitiveCommand("remove_account"))
|
||||||
@admin_only
|
@admin_only
|
||||||
async def remove_account_command(message: Message):
|
async def remove_account_command(message: Message):
|
||||||
"""
|
"""
|
||||||
Деактивировать счет(а)
|
Деактивировать счет(а) (регистронезависимо)
|
||||||
Формат: /remove_account <account_number1> [account_number2] [account_number3] ...
|
Формат: /remove_account <account_number1> [account_number2] [account_number3] ...
|
||||||
Можно указать несколько счетов через пробел для массового удаления
|
Можно указать несколько счетов через пробел для массового удаления
|
||||||
"""
|
"""
|
||||||
@@ -504,11 +505,11 @@ async def remove_account_command(message: Message):
|
|||||||
await message.answer(f"❌ Критическая ошибка: {str(e)}")
|
await message.answer(f"❌ Критическая ошибка: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("verify_winner"))
|
@router.message(CaseInsensitiveCommand("verify_winner"))
|
||||||
@admin_only
|
@admin_only
|
||||||
async def verify_winner_command(message: Message):
|
async def verify_winner_command(message: Message):
|
||||||
"""
|
"""
|
||||||
Подтвердить выигрыш по коду верификации
|
Подтвердить выигрыш по коду верификации (регистронезависимо)
|
||||||
Формат: /verify_winner <verification_code> <lottery_id>
|
Формат: /verify_winner <verification_code> <lottery_id>
|
||||||
Пример: /verify_winner AB12CD34 1
|
Пример: /verify_winner AB12CD34 1
|
||||||
"""
|
"""
|
||||||
@@ -595,11 +596,11 @@ async def verify_winner_command(message: Message):
|
|||||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("winner_status"))
|
@router.message(CaseInsensitiveCommand("winner_status"))
|
||||||
@admin_only
|
@admin_only
|
||||||
async def winner_status_command(message: Message):
|
async def winner_status_command(message: Message):
|
||||||
"""
|
"""
|
||||||
Показать статус всех победителей розыгрыша
|
Показать статус всех победителей розыгрыша (регистронезависимо)
|
||||||
Формат: /winner_status <lottery_id>
|
Формат: /winner_status <lottery_id>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -668,11 +669,11 @@ async def winner_status_command(message: Message):
|
|||||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("user_info"))
|
@router.message(CaseInsensitiveCommand("user_info"))
|
||||||
@admin_only
|
@admin_only
|
||||||
async def user_info_command(message: Message):
|
async def user_info_command(message: Message):
|
||||||
"""
|
"""
|
||||||
Показать информацию о пользователе
|
Показать информацию о пользователе (регистронезависимо)
|
||||||
Формат: /user_info <club_card>
|
Формат: /user_info <club_card>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKe
|
|||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||||
from src.core.chat_services import (
|
from src.core.chat_services import (
|
||||||
ChatSettingsService,
|
ChatSettingsService,
|
||||||
BanService,
|
BanService,
|
||||||
@@ -29,10 +30,10 @@ def get_chat_mode_keyboard() -> InlineKeyboardMarkup:
|
|||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("chat_mode"))
|
@router.message(CaseInsensitiveCommand("chat_mode"))
|
||||||
@admin_only
|
@admin_only
|
||||||
async def cmd_chat_mode(message: Message):
|
async def cmd_chat_mode(message: Message):
|
||||||
"""Команда управления режимом чата"""
|
"""Команда управления режимом чата (регистронезависимо)"""
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||||
@@ -68,10 +69,10 @@ async def process_chat_mode(callback: CallbackQuery):
|
|||||||
await callback.answer("✅ Режим изменен")
|
await callback.answer("✅ Режим изменен")
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("set_forward"))
|
@router.message(CaseInsensitiveCommand("set_forward"))
|
||||||
@admin_only
|
@admin_only
|
||||||
async def cmd_set_forward(message: Message):
|
async def cmd_set_forward(message: Message):
|
||||||
"""Установить ID канала для пересылки"""
|
"""Установить ID канала для пересылки (регистронезависимо)"""
|
||||||
|
|
||||||
args = message.text.split(maxsplit=1)
|
args = message.text.split(maxsplit=1)
|
||||||
if len(args) < 2:
|
if len(args) < 2:
|
||||||
@@ -100,10 +101,10 @@ async def cmd_set_forward(message: Message):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("global_ban"))
|
@router.message(CaseInsensitiveCommand("global_ban"))
|
||||||
@admin_only
|
@admin_only
|
||||||
async def cmd_global_ban(message: Message):
|
async def cmd_global_ban(message: Message):
|
||||||
"""Включить/выключить глобальный бан чата"""
|
"""Включить/выключить глобальный бан чата (регистронезависимо)"""
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||||
@@ -126,10 +127,10 @@ async def cmd_global_ban(message: Message):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("ban"))
|
@router.message(CaseInsensitiveCommand("ban"))
|
||||||
@admin_only
|
@admin_only
|
||||||
async def cmd_ban(message: Message):
|
async def cmd_ban(message: Message):
|
||||||
"""Забанить пользователя"""
|
"""Забанить пользователя (регистронезависимо)"""
|
||||||
|
|
||||||
# Проверяем является ли это ответом на сообщение
|
# Проверяем является ли это ответом на сообщение
|
||||||
if message.reply_to_message:
|
if message.reply_to_message:
|
||||||
@@ -191,10 +192,10 @@ async def cmd_ban(message: Message):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("unban"))
|
@router.message(CaseInsensitiveCommand("unban"))
|
||||||
@admin_only
|
@admin_only
|
||||||
async def cmd_unban(message: Message):
|
async def cmd_unban(message: Message):
|
||||||
"""Разбанить пользователя"""
|
"""Разбанить пользователя (регистронезависимо)"""
|
||||||
|
|
||||||
# Проверяем является ли это ответом на сообщение
|
# Проверяем является ли это ответом на сообщение
|
||||||
if message.reply_to_message:
|
if message.reply_to_message:
|
||||||
@@ -232,10 +233,10 @@ async def cmd_unban(message: Message):
|
|||||||
await message.answer("❌ Пользователь не был забанен")
|
await message.answer("❌ Пользователь не был забанен")
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("banlist"))
|
@router.message(CaseInsensitiveCommand("banlist"))
|
||||||
@admin_only
|
@admin_only
|
||||||
async def cmd_banlist(message: Message):
|
async def cmd_banlist(message: Message):
|
||||||
"""Показать список забаненных пользователей"""
|
"""Показать список заблокированных пользователей (регистронезависимо)"""
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
banned_users = await BanService.get_banned_users(session, active_only=True)
|
banned_users = await BanService.get_banned_users(session, active_only=True)
|
||||||
@@ -262,10 +263,10 @@ async def cmd_banlist(message: Message):
|
|||||||
await message.answer(text, parse_mode="HTML")
|
await message.answer(text, parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("delete_msg"))
|
@router.message(CaseInsensitiveCommand("delete_msg"))
|
||||||
@admin_only
|
@admin_only
|
||||||
async def cmd_delete_message(message: Message):
|
async def cmd_delete_message(message: Message):
|
||||||
"""Удалить сообщение из чата (пометить как удаленное)"""
|
"""Удалить сообщение из чата (пометить как удаленное) (регистронезависимо)"""
|
||||||
|
|
||||||
if not message.reply_to_message:
|
if not message.reply_to_message:
|
||||||
await message.answer(
|
await message.answer(
|
||||||
@@ -329,10 +330,10 @@ async def cmd_delete_message(message: Message):
|
|||||||
await message.answer("❌ Не удалось удалить сообщение")
|
await message.answer("❌ Не удалось удалить сообщение")
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("chat_stats"))
|
@router.message(CaseInsensitiveCommand("chat_stats"))
|
||||||
@admin_only
|
@admin_only
|
||||||
async def cmd_chat_stats(message: Message):
|
async def cmd_chat_stats(message: Message):
|
||||||
"""Статистика чата"""
|
"""Статистика чата (регистронезависимо)"""
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||||
|
|||||||
273
src/handlers/admin_emoji_handlers.py
Normal file
273
src/handlers/admin_emoji_handlers.py
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
"""
|
||||||
|
Хендлеры для управления кастомными эмодзи админом
|
||||||
|
Админ отправляет эмодзи боту, бот сохраняет emoji_id и использует его в сообщениях в чатах
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from aiogram import Router, F
|
||||||
|
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
from aiogram.filters import Command, StateFilter
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..core.database import async_session_maker
|
||||||
|
from ..core.config import ADMIN_IDS
|
||||||
|
from ..core.emoji_mapping_service import EmojiMappingService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
class EmojiStates(StatesGroup):
|
||||||
|
waiting_for_emoji = State()
|
||||||
|
waiting_for_description = State()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("add_emoji"), StateFilter(None))
|
||||||
|
async def add_emoji_start(message: Message, state: FSMContext):
|
||||||
|
"""Начать процесс добавления нового эмодзи"""
|
||||||
|
if message.from_user.id not in ADMIN_IDS:
|
||||||
|
await message.answer("❌ Эта команда доступна только администраторам")
|
||||||
|
return
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
"🎨 Отправьте эмодзи, который хотите зарегистрировать.\n\n"
|
||||||
|
"Бот получит его <code>emoji_id</code> и будет использовать этот ID "
|
||||||
|
"при отправке сообщений в чаты, чтобы эмодзи выглядел точно так же.",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
await state.set_state(EmojiStates.waiting_for_emoji)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(EmojiStates.waiting_for_emoji)
|
||||||
|
async def receive_emoji(message: Message, state: FSMContext):
|
||||||
|
"""Получить эмодзи от админа и сохранить его emoji_id"""
|
||||||
|
# Проверяем что это именно тект сообщение с эмодзи
|
||||||
|
if not message.text or len(message.text) > 10:
|
||||||
|
await message.answer(
|
||||||
|
"❌ Пожалуйста, отправьте просто эмодзи или маленький текст с эмодзи"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
emoji_text = message.text.strip()
|
||||||
|
|
||||||
|
# Проверяем что хотя бы один символ это эмодзи
|
||||||
|
has_emoji = any(ord(c) > 127 for c in emoji_text)
|
||||||
|
if not has_emoji:
|
||||||
|
await message.answer(
|
||||||
|
"❌ Текст не содержит эмодзи. Пожалуйста, отправьте эмодзи"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Извлекаем emoji_id из entities если это есть
|
||||||
|
emoji_id = None
|
||||||
|
|
||||||
|
# Проверяем есть ли entities в сообщении (custom emoji имеют свой entitytype)
|
||||||
|
if message.entities:
|
||||||
|
for entity in message.entities:
|
||||||
|
if entity.type == "custom_emoji":
|
||||||
|
# Получаем text с этим entity
|
||||||
|
emoji_id = entity.custom_emoji_id
|
||||||
|
break
|
||||||
|
|
||||||
|
# Если нет custom_emoji entity, пробуем другой способ
|
||||||
|
if not emoji_id:
|
||||||
|
# Используем встроенный способ Telegram - отправляем тестовое сообщение с этим эмодзи
|
||||||
|
# и смотрим entities
|
||||||
|
try:
|
||||||
|
# Отправляем сообщение с эмодзи обратно
|
||||||
|
test_msg = await message.answer(
|
||||||
|
f"Тестирую эмодзи: {emoji_text}",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
# Пытаемся получить emoji_id из реакции
|
||||||
|
# В Telegram для premium emoji нужно обращаться к API
|
||||||
|
# Но мы можем просто использовать сам emoji как ID - он уникален
|
||||||
|
emoji_id = emoji_text
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error testing emoji: {e}")
|
||||||
|
emoji_id = emoji_text
|
||||||
|
|
||||||
|
# Сохраняем в состояние
|
||||||
|
await state.update_data(emoji_text=emoji_text, emoji_id=emoji_id if emoji_id else emoji_text)
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
f"✅ Получил эмодзи: <code>{emoji_text}</code>\n\n"
|
||||||
|
f"Теперь отправьте описание этого эмодзи (для чего его использовать?)\n"
|
||||||
|
f"Например: <code>Для лотереи</code>, <code>Для победителей</code> и т.д.",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
await state.set_state(EmojiStates.waiting_for_description)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(EmojiStates.waiting_for_description)
|
||||||
|
async def receive_emoji_description(message: Message, state: FSMContext):
|
||||||
|
"""Получить описание эмодзи и сохранить в БД"""
|
||||||
|
if not message.text:
|
||||||
|
await message.answer("❌ Пожалуйста, отправьте текстовое описание")
|
||||||
|
return
|
||||||
|
|
||||||
|
description = message.text.strip()
|
||||||
|
data = await state.get_data()
|
||||||
|
emoji_text = data.get("emoji_text")
|
||||||
|
emoji_id = data.get("emoji_id")
|
||||||
|
|
||||||
|
# Сохраняем в БД
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
emoji_service = EmojiMappingService(session)
|
||||||
|
|
||||||
|
# Проверяем не существует ли уже такой эмодзи
|
||||||
|
existing = await emoji_service.get_emoji_by_text(emoji_text, message.from_user.id)
|
||||||
|
if existing:
|
||||||
|
await message.answer(
|
||||||
|
f"⚠️ Вы уже зарегистрировали этот эмодзи: {emoji_text}\n"
|
||||||
|
f"Описание: <code>{existing.description}</code>",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
emoji_mapping = await emoji_service.register_emoji(
|
||||||
|
emoji_text=emoji_text,
|
||||||
|
emoji_id=emoji_id,
|
||||||
|
admin_id=message.from_user.id,
|
||||||
|
description=description
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
f"✅ <b>Эмодзи успешно зарегистрировано!</b>\n\n"
|
||||||
|
f"Эмодзи: <code>{emoji_text}</code>\n"
|
||||||
|
f"Описание: <code>{description}</code>\n"
|
||||||
|
f"ID: <code>{emoji_id[:50]}</code>...\n\n"
|
||||||
|
f"Теперь это эмодзи будет автоматически использоваться в сообщениях бота.",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error registering emoji: {e}")
|
||||||
|
await message.answer(
|
||||||
|
f"❌ Ошибка при сохранении эмодзи: {str(e)}",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("my_emojis"))
|
||||||
|
async def list_my_emojis(message: Message):
|
||||||
|
"""Показать все эмодзи, добавленные этим админом"""
|
||||||
|
if message.from_user.id not in ADMIN_IDS:
|
||||||
|
await message.answer("❌ Эта команда доступна только администраторам")
|
||||||
|
return
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
emoji_service = EmojiMappingService(session)
|
||||||
|
emojis = await emoji_service.get_all_emoji_by_admin(message.from_user.id)
|
||||||
|
|
||||||
|
if not emojis:
|
||||||
|
await message.answer(
|
||||||
|
"📭 Вы еще не добавили ни один эмодзи.\n\n"
|
||||||
|
"Используйте /add_emoji чтобы добавить новый эмодзи"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
text = "🎨 <b>Ваши зарегистрированные эмодзи:</b>\n\n"
|
||||||
|
for emoji in emojis:
|
||||||
|
text += (
|
||||||
|
f"<code>{emoji.emoji_text}</code> — {emoji.description}\n"
|
||||||
|
f" ID: <code>{emoji.emoji_id[:30]}</code>...\n"
|
||||||
|
f" Добавлено: <code>{emoji.created_at.strftime('%d.%m.%Y %H:%M')}</code>\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.answer(text, parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("all_emojis"))
|
||||||
|
async def list_all_emojis(message: Message):
|
||||||
|
"""Показать все зарегистрированные эмодзи (для всех админов)"""
|
||||||
|
if message.from_user.id not in ADMIN_IDS:
|
||||||
|
await message.answer("❌ Эта команда доступна только администраторам")
|
||||||
|
return
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
emoji_service = EmojiMappingService(session)
|
||||||
|
emojis = await emoji_service.get_all_emojis()
|
||||||
|
|
||||||
|
if not emojis:
|
||||||
|
await message.answer(
|
||||||
|
"📭 Нет зарегистрированных эмодзи в системе"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
text = "🎨 <b>Все зарегистрированные эмодзи в системе:</b>\n\n"
|
||||||
|
for emoji in emojis:
|
||||||
|
text += (
|
||||||
|
f"<code>{emoji.emoji_text}</code> — {emoji.description}\n"
|
||||||
|
f" Админ: <code>{emoji.admin.first_name or 'Unknown'}</code> "
|
||||||
|
f"(ID: {emoji.admin_id})\n"
|
||||||
|
f" Добавлено: <code>{emoji.created_at.strftime('%d.%m.%Y %H:%M')}</code>\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.answer(text, parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("delete_emoji"))
|
||||||
|
async def delete_emoji_start(message: Message, state: FSMContext):
|
||||||
|
"""Удалить эмодзи"""
|
||||||
|
if message.from_user.id not in ADMIN_IDS:
|
||||||
|
await message.answer("❌ Эта команда доступна только администраторам")
|
||||||
|
return
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
emoji_service = EmojiMappingService(session)
|
||||||
|
emojis = await emoji_service.get_all_emoji_by_admin(message.from_user.id)
|
||||||
|
|
||||||
|
if not emojis:
|
||||||
|
await message.answer(
|
||||||
|
"📭 У вас нет зарегистрированных эмодзи"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Создаем клавиатуру для выбора эмодзи
|
||||||
|
buttons = []
|
||||||
|
for emoji in emojis:
|
||||||
|
buttons.append([
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{emoji.emoji_text} ({emoji.description})",
|
||||||
|
callback_data=f"delete_emoji_{emoji.emoji_id}"
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
kb = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
await message.answer(
|
||||||
|
"🗑️ Выберите эмодзи для удаления:",
|
||||||
|
reply_markup=kb
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("delete_emoji_"))
|
||||||
|
async def delete_emoji_confirm(callback: CallbackQuery):
|
||||||
|
"""Подтвердить удаление эмодзи"""
|
||||||
|
emoji_id = callback.data.replace("delete_emoji_", "")
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
emoji_service = EmojiMappingService(session)
|
||||||
|
emoji = await emoji_service.get_emoji_by_id(emoji_id)
|
||||||
|
|
||||||
|
if not emoji:
|
||||||
|
await callback.answer("❌ Эмодзи не найден", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if emoji.admin_id != callback.from_user.id and callback.from_user.id not in ADMIN_IDS:
|
||||||
|
await callback.answer("❌ Вы не можете удалить эмодзи другого админа", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
success = await emoji_service.delete_emoji(emoji_id)
|
||||||
|
if success:
|
||||||
|
await callback.answer(
|
||||||
|
f"✅ Эмодзи <code>{emoji.emoji_text}</code> удалено",
|
||||||
|
show_alert=True
|
||||||
|
)
|
||||||
|
await callback.message.delete()
|
||||||
|
else:
|
||||||
|
await callback.answer("❌ Ошибка при удалении эмодзи", show_alert=True)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,11 @@ from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKe
|
|||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.fsm.state import State, StatesGroup
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
from aiogram.filters import StateFilter, Command
|
from aiogram.filters import StateFilter, Command
|
||||||
|
|
||||||
|
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import List, Dict, Optional, Set
|
from typing import List, Dict, Optional, Set, Any
|
||||||
from collections import deque
|
from collections import deque
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -43,9 +45,9 @@ def _contains_account_numbers(text: str) -> bool:
|
|||||||
router = Router(name='chat_router')
|
router = Router(name='chat_router')
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("chat"))
|
@router.message(CaseInsensitiveCommand("chat"))
|
||||||
async def enter_chat_command(message: Message, state: FSMContext):
|
async def enter_chat_command(message: Message, state: FSMContext):
|
||||||
"""Войти в режим чата через команду /chat"""
|
"""Войти в режим чата через команду /chat (регистронезависимо)"""
|
||||||
await enter_chat(message, state)
|
await enter_chat(message, state)
|
||||||
|
|
||||||
|
|
||||||
@@ -58,26 +60,37 @@ async def enter_chat_callback(callback: CallbackQuery, state: FSMContext):
|
|||||||
|
|
||||||
async def enter_chat(message: Message, state: FSMContext):
|
async def enter_chat(message: Message, state: FSMContext):
|
||||||
"""Общая функция входа в чат"""
|
"""Общая функция входа в чат"""
|
||||||
|
from src.utils.keyboards import get_chat_reply_keyboard
|
||||||
|
|
||||||
await state.set_state(ChatStates.in_chat)
|
await state.set_state(ChatStates.in_chat)
|
||||||
|
|
||||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||||
[InlineKeyboardButton(text="🚪 Выйти из чата", callback_data="exit_chat")],
|
[InlineKeyboardButton(text="🚪 Выйти из чата", callback_data="exit_chat")],
|
||||||
[InlineKeyboardButton(text="🏠 В главное меню", callback_data="back_to_main")]
|
[InlineKeyboardButton(text="🏠 Главная", callback_data="back_to_main")]
|
||||||
])
|
])
|
||||||
|
|
||||||
|
# Обычная клавиатура для чата
|
||||||
|
reply_keyboard = get_chat_reply_keyboard()
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"💬 <b>Вы вошли в режим чата</b>\n\n"
|
"💬 <b>Вы вошли в режим чата</b>\n\n"
|
||||||
"Теперь все ваши сообщения будут рассылаться участникам.\n"
|
"Теперь все ваши сообщения будут рассылаться участникам.\n"
|
||||||
"Вы можете отправлять текст, фото, видео, документы и стикеры.\n\n"
|
"Вы можете отправлять текст, фото, видео, документы и стикеры.\n\n"
|
||||||
"Для выхода нажмите кнопку ниже или отправьте /exit",
|
"Для выхода нажмите кнопку ниже или отправьте /exit",
|
||||||
reply_markup=keyboard,
|
reply_markup=reply_keyboard, # Обычная клавиатура
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Inline клавиатура отдельным сообщением
|
||||||
|
await message.answer(
|
||||||
|
"Выберите действие:",
|
||||||
|
reply_markup=keyboard
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("exit"), StateFilter(ChatStates.in_chat))
|
@router.message(CaseInsensitiveCommand("exit"), StateFilter(ChatStates.in_chat))
|
||||||
async def exit_chat_command(message: Message, state: FSMContext):
|
async def exit_chat_command(message: Message, state: FSMContext):
|
||||||
"""Выйти из режима чата через команду /exit"""
|
"""Выйти из режима чата через команду /exit (регистронезависимо)"""
|
||||||
await exit_chat(message, state)
|
await exit_chat(message, state)
|
||||||
|
|
||||||
|
|
||||||
@@ -90,223 +103,79 @@ async def exit_chat_callback(callback: CallbackQuery, state: FSMContext):
|
|||||||
|
|
||||||
async def exit_chat(message: Message, state: FSMContext):
|
async def exit_chat(message: Message, state: FSMContext):
|
||||||
"""Общая функция выхода из чата"""
|
"""Общая функция выхода из чата"""
|
||||||
|
from src.utils.keyboards import get_main_reply_keyboard
|
||||||
|
from src.core.config import ADMIN_IDS
|
||||||
|
from src.core.services import UserService
|
||||||
|
from src.core.database import async_session_maker
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
|
# Получаем информацию о пользователе
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||||
|
is_registered = user.is_registered if user else False
|
||||||
|
is_admin_user = message.from_user.id in ADMIN_IDS
|
||||||
|
|
||||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||||
[InlineKeyboardButton(text="💬 Войти в чат", callback_data="enter_chat")],
|
[InlineKeyboardButton(text="💬 Войти в чат", callback_data="enter_chat")],
|
||||||
[InlineKeyboardButton(text="🏠 В главное меню", callback_data="back_to_main")]
|
[InlineKeyboardButton(text="🏠 Главная", callback_data="back_to_main")]
|
||||||
])
|
])
|
||||||
|
|
||||||
|
# Обычная клавиатура
|
||||||
|
reply_keyboard = get_main_reply_keyboard(is_admin=is_admin_user, is_registered=is_registered)
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"✅ <b>Вы вышли из режима чата</b>\n\n"
|
"✅ <b>Вы вышли из режима чата</b>\n\n"
|
||||||
"Ваши сообщения больше не будут рассылаться.",
|
"Ваши сообщения больше не будут рассылаться.",
|
||||||
reply_markup=keyboard,
|
reply_markup=reply_keyboard, # Обычная клавиатура
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Inline клавиатура отдельным сообщением
|
||||||
|
await message.answer(
|
||||||
|
"Выберите действие:",
|
||||||
|
reply_markup=keyboard
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Настройки для планировщика рассылки
|
@router.message(StateFilter(ChatStates.in_chat), F.text)
|
||||||
BATCH_SIZE = 20 # Количество сообщений в пакете
|
async def check_exit_keywords(message: Message, state: FSMContext):
|
||||||
BATCH_DELAY = 1.0 # Задержка между пакетами в секундах
|
"""Проверка на ключевые слова для выхода из чата + обработка сообщений"""
|
||||||
|
|
||||||
# Защита от дубликатов сообщений (храним последние 100 message_id)
|
|
||||||
_processed_messages: deque = deque(maxlen=100)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_message_processed(message_id: int) -> bool:
|
|
||||||
"""Проверка, было ли сообщение уже обработано"""
|
|
||||||
if message_id in _processed_messages:
|
|
||||||
return True
|
|
||||||
_processed_messages.append(message_id)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def get_all_active_users(session: AsyncSession) -> List:
|
|
||||||
"""Получить всех пользователей для рассылки (зарегистрированные + админы)"""
|
|
||||||
users = await UserService.get_all_users(session)
|
|
||||||
# Рассылаем зарегистрированным пользователям И админам (даже если они не зарегистрированы)
|
|
||||||
return [u for u in users if u.is_registered or u.telegram_id in ADMIN_IDS]
|
|
||||||
|
|
||||||
|
|
||||||
async def broadcast_message_with_scheduler(
|
|
||||||
message: Message,
|
|
||||||
exclude_user_id: Optional[int] = None,
|
|
||||||
admin_only: bool = False,
|
|
||||||
sender_info: Optional[str] = None
|
|
||||||
) -> tuple[Dict[str, int], int, int]:
|
|
||||||
"""
|
|
||||||
Разослать сообщение всем пользователям с планировщиком (пакетная отправка).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
message: Сообщение для рассылки
|
|
||||||
exclude_user_id: ID пользователя для исключения
|
|
||||||
admin_only: Рассылать только админам
|
|
||||||
sender_info: Информация об отправителе (для показа админам)
|
|
||||||
|
|
||||||
Возвращает: (forwarded_ids, success_count, fail_count)
|
|
||||||
"""
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
users = await get_all_active_users(session)
|
|
||||||
|
|
||||||
if exclude_user_id:
|
|
||||||
users = [u for u in users if u.telegram_id != exclude_user_id]
|
|
||||||
|
|
||||||
# Если только для админов - фильтруем
|
|
||||||
if admin_only:
|
|
||||||
users = [u for u in users if u.telegram_id in ADMIN_IDS]
|
|
||||||
|
|
||||||
forwarded_ids = {}
|
|
||||||
success_count = 0
|
|
||||||
fail_count = 0
|
|
||||||
|
|
||||||
# Разбиваем на пакеты
|
|
||||||
for i in range(0, len(users), BATCH_SIZE):
|
|
||||||
batch = users[i:i + BATCH_SIZE]
|
|
||||||
|
|
||||||
# Отправляем пакет
|
|
||||||
tasks = []
|
|
||||||
for user in batch:
|
|
||||||
# Для админов добавляем информацию об отправителе, если сообщение от обычного пользователя
|
|
||||||
if sender_info and user.telegram_id in ADMIN_IDS:
|
|
||||||
tasks.append(_send_message_to_admin_with_sender(message, user.telegram_id, sender_info))
|
|
||||||
else:
|
|
||||||
tasks.append(_send_message_to_user(message, user.telegram_id))
|
|
||||||
|
|
||||||
# Ждем завершения пакета
|
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
||||||
|
|
||||||
# Обрабатываем результаты
|
|
||||||
for user, result in zip(batch, results):
|
|
||||||
if isinstance(result, Exception):
|
|
||||||
fail_count += 1
|
|
||||||
elif result is not None:
|
|
||||||
forwarded_ids[str(user.telegram_id)] = result
|
|
||||||
success_count += 1
|
|
||||||
else:
|
|
||||||
fail_count += 1
|
|
||||||
|
|
||||||
# Задержка между пакетами (если есть еще пакеты)
|
|
||||||
if i + BATCH_SIZE < len(users):
|
|
||||||
await asyncio.sleep(BATCH_DELAY)
|
|
||||||
|
|
||||||
return forwarded_ids, success_count, fail_count
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_message_to_user(message: Message, user_telegram_id: int) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Отправить сообщение конкретному пользователю.
|
|
||||||
Возвращает message_id при успехе или None при ошибке.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
sent_msg = await message.copy_to(user_telegram_id)
|
|
||||||
return sent_msg.message_id
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Failed to send message to {user_telegram_id}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_message_to_admin_with_sender(message: Message, admin_telegram_id: int, sender_info: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Отправить сообщение админу с информацией об отправителе.
|
|
||||||
Возвращает message_id при успехе или None при ошибке.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Формируем текст с информацией об отправителе
|
|
||||||
header = f"📨 <b>Сообщение от {sender_info}:</b>\n\n"
|
|
||||||
|
|
||||||
if message.text:
|
|
||||||
# Текстовое сообщение
|
|
||||||
sent_msg = await message.bot.send_message(
|
|
||||||
admin_telegram_id,
|
|
||||||
header + message.text,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
elif message.photo:
|
|
||||||
# Фото
|
|
||||||
caption = header + (message.caption or "")
|
|
||||||
sent_msg = await message.bot.send_photo(
|
|
||||||
admin_telegram_id,
|
|
||||||
photo=message.photo[-1].file_id,
|
|
||||||
caption=caption,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
elif message.video:
|
|
||||||
# Видео
|
|
||||||
caption = header + (message.caption or "")
|
|
||||||
sent_msg = await message.bot.send_video(
|
|
||||||
admin_telegram_id,
|
|
||||||
video=message.video.file_id,
|
|
||||||
caption=caption,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
elif message.document:
|
|
||||||
# Документ
|
|
||||||
caption = header + (message.caption or "")
|
|
||||||
sent_msg = await message.bot.send_document(
|
|
||||||
admin_telegram_id,
|
|
||||||
document=message.document.file_id,
|
|
||||||
caption=caption,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
elif message.animation:
|
|
||||||
# GIF
|
|
||||||
caption = header + (message.caption or "")
|
|
||||||
sent_msg = await message.bot.send_animation(
|
|
||||||
admin_telegram_id,
|
|
||||||
animation=message.animation.file_id,
|
|
||||||
caption=caption,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
elif message.sticker:
|
|
||||||
# Стикер - сначала отправляем заголовок, потом стикер
|
|
||||||
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
|
|
||||||
sent_msg = await message.bot.send_sticker(admin_telegram_id, sticker=message.sticker.file_id)
|
|
||||||
elif message.voice:
|
|
||||||
# Голосовое сообщение
|
|
||||||
sent_msg = await message.bot.send_voice(
|
|
||||||
admin_telegram_id,
|
|
||||||
voice=message.voice.file_id,
|
|
||||||
caption=header,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
|
||||||
elif message.video_note:
|
|
||||||
# Видео-кружок
|
|
||||||
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
|
|
||||||
sent_msg = await message.bot.send_video_note(admin_telegram_id, video_note=message.video_note.file_id)
|
|
||||||
else:
|
|
||||||
# Неизвестный тип - просто копируем
|
|
||||||
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
|
|
||||||
sent_msg = await message.copy_to(admin_telegram_id)
|
|
||||||
|
|
||||||
return sent_msg.message_id
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Failed to send message with sender info to admin {admin_telegram_id}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, Optional[int]]:
|
|
||||||
"""Переслать сообщение в канал/группу"""
|
|
||||||
try:
|
|
||||||
# Пересылаем сообщение в канал
|
|
||||||
sent_msg = await message.forward(channel_id)
|
|
||||||
return True, sent_msg.message_id
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Failed to forward message to channel {channel_id}: {e}")
|
|
||||||
return False, None
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.text, StateFilter(ChatStates.in_chat))
|
|
||||||
async def handle_text_message(message: Message, state: FSMContext):
|
|
||||||
"""Обработчик текстовых сообщений"""
|
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
text = message.text.strip().lower()
|
||||||
|
|
||||||
|
# Проверяем ключевые слова для выхода
|
||||||
|
exit_keywords = ['/start', 'start', 'старт', '/exit']
|
||||||
|
|
||||||
|
if text in exit_keywords:
|
||||||
|
if text in ['/start', 'start', 'старт']:
|
||||||
|
# Выходим из чата и показываем главное меню
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
from src.components.ui import UserUI
|
||||||
|
keyboard = UserUI.get_main_menu_keyboard(message.from_user.id)
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
"🏠 <b>Главное меню</b>\n\n"
|
||||||
|
"Вы вышли из режима чата.",
|
||||||
|
reply_markup=keyboard,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
return # Не обрабатываем дальше
|
||||||
|
else:
|
||||||
|
# Для /exit просто выходим
|
||||||
|
await exit_chat(message, state)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ===== ОБРАБОТКА ОБЫЧНОГО СООБЩЕНИЯ ЧАТА =====
|
||||||
# Защита от дубликатов - если сообщение уже обработано, пропускаем
|
# Защита от дубликатов - если сообщение уже обработано, пропускаем
|
||||||
if _is_message_processed(message.message_id):
|
if _is_message_processed(message.message_id):
|
||||||
logger.warning(f"[CHAT] Дубликат сообщения {message.message_id}, пропускаем")
|
logger.warning(f"[CHAT] Дубликат сообщения {message.message_id}, пропускаем")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"[CHAT] handle_text_message вызван: user={message.from_user.id}, text={message.text[:50] if message.text else 'None'}")
|
logger.info(f"[CHAT] check_exit_keywords вызван для обработки: user={message.from_user.id}, text={message.text[:50] if message.text else 'None'}")
|
||||||
|
|
||||||
# ПРОВЕРКА СЧЕТОВ: Если админ отправил сообщение с номерами счетов - НЕ рассылаем
|
# ПРОВЕРКА СЧЕТОВ: Если админ отправил сообщение с номерами счетов - НЕ рассылаем
|
||||||
# Пропускаем для account_router (который идет после chat_router)
|
# Пропускаем для account_router (который идет после chat_router)
|
||||||
@@ -443,19 +312,12 @@ async def handle_text_message(message: Message, state: FSMContext):
|
|||||||
# Обрабатываем в зависимости от режима
|
# Обрабатываем в зависимости от режима
|
||||||
if settings.mode == 'broadcast':
|
if settings.mode == 'broadcast':
|
||||||
# Режим рассылки с планировщиком
|
# Режим рассылки с планировщиком
|
||||||
# Формируем информацию об отправителе для админов (если это не админ)
|
# Передаем объект user для динамического формирования подписей
|
||||||
sender_info = None
|
|
||||||
if not is_admin(message.from_user.id):
|
|
||||||
sender_name = f"@{user.username}" if user.username else user.first_name
|
|
||||||
if user.club_card_number:
|
|
||||||
sender_name += f" (карта: {user.club_card_number})"
|
|
||||||
sender_info = sender_name
|
|
||||||
|
|
||||||
# ВСЕГДА исключаем отправителя - он не должен получать своё же сообщение
|
# ВСЕГДА исключаем отправителя - он не должен получать своё же сообщение
|
||||||
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
||||||
message,
|
message,
|
||||||
exclude_user_id=message.from_user.id,
|
sender_user=user,
|
||||||
sender_info=sender_info
|
exclude_user_id=message.from_user.id
|
||||||
)
|
)
|
||||||
|
|
||||||
# Сохраняем сообщение в историю
|
# Сохраняем сообщение в историю
|
||||||
@@ -500,6 +362,305 @@ async def handle_text_message(message: Message, state: FSMContext):
|
|||||||
await message.answer("❌ Не удалось переслать сообщение")
|
await message.answer("❌ Не удалось переслать сообщение")
|
||||||
|
|
||||||
|
|
||||||
|
# Настройки для планировщика рассылки
|
||||||
|
BATCH_SIZE = 20 # Количество сообщений в пакете
|
||||||
|
BATCH_DELAY = 1.0 # Задержка между пакетами в секундах
|
||||||
|
|
||||||
|
# Защита от дубликатов сообщений (храним последние 100 message_id)
|
||||||
|
_processed_messages: deque = deque(maxlen=100)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_message_processed(message_id: int) -> bool:
|
||||||
|
"""Проверка, было ли сообщение уже обработано"""
|
||||||
|
if message_id in _processed_messages:
|
||||||
|
return True
|
||||||
|
_processed_messages.append(message_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_active_users(session: AsyncSession) -> List:
|
||||||
|
"""Получить всех пользователей для рассылки (всем, кто когда-либо общался с ботом)"""
|
||||||
|
users = await UserService.get_all_users(session)
|
||||||
|
# Рассылаем всем пользователям - и зарегистрированным, и незарегистрированным
|
||||||
|
# Они все имеют право общаться в чате (главное - что они вошли в чат)
|
||||||
|
return users
|
||||||
|
|
||||||
|
|
||||||
|
async def broadcast_message_with_scheduler(
|
||||||
|
message: Message,
|
||||||
|
sender_user: Any, # User model object
|
||||||
|
exclude_user_id: Optional[int] = None,
|
||||||
|
admin_only: bool = False
|
||||||
|
) -> tuple[Dict[str, int], int, int]:
|
||||||
|
"""
|
||||||
|
Разослать сообщение всем пользователям с планировщиком (пакетная отправка).
|
||||||
|
Подписи формируются динамически в зависимости от получателя:
|
||||||
|
- Админы видят: nickname (карта: XXXX)
|
||||||
|
- Обычные пользователи видят: nickname (от пользователя) или "Админ" (от админа)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Сообщение для рассылки
|
||||||
|
sender_user: Объект User отправителя
|
||||||
|
exclude_user_id: ID пользователя для исключения
|
||||||
|
admin_only: Рассылать только админам
|
||||||
|
|
||||||
|
Возвращает: (forwarded_ids, success_count, fail_count)
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
users = await get_all_active_users(session)
|
||||||
|
|
||||||
|
logger.info(f"[CHAT] broadcast_message_with_scheduler: всего пользователей для рассылки: {len(users)}")
|
||||||
|
|
||||||
|
if exclude_user_id:
|
||||||
|
users = [u for u in users if u.telegram_id != exclude_user_id]
|
||||||
|
logger.info(f"[CHAT] После исключения отправителя: {len(users)} пользователей")
|
||||||
|
|
||||||
|
# Если только для админов - фильтруем
|
||||||
|
if admin_only:
|
||||||
|
users = [u for u in users if u.telegram_id in ADMIN_IDS]
|
||||||
|
logger.info(f"[CHAT] Фильтр админов: {len(users)} пользователей")
|
||||||
|
|
||||||
|
forwarded_ids = {}
|
||||||
|
success_count = 0
|
||||||
|
fail_count = 0
|
||||||
|
|
||||||
|
# Разбиваем на пакеты
|
||||||
|
for i in range(0, len(users), BATCH_SIZE):
|
||||||
|
batch = users[i:i + BATCH_SIZE]
|
||||||
|
|
||||||
|
# Отправляем пакет
|
||||||
|
tasks = []
|
||||||
|
for recipient_user in batch:
|
||||||
|
# Формируем подпись в зависимости от получателя
|
||||||
|
if recipient_user.telegram_id in ADMIN_IDS:
|
||||||
|
# Админы видят полную информацию: nickname (карта: XXXX)
|
||||||
|
sender_name = sender_user.nickname if sender_user.nickname else (
|
||||||
|
f"@{sender_user.username}" if sender_user.username else sender_user.first_name
|
||||||
|
)
|
||||||
|
if sender_user.club_card_number:
|
||||||
|
sender_name += f" (карта: {sender_user.club_card_number})"
|
||||||
|
sender_info = sender_name
|
||||||
|
tasks.append(_send_message_to_admin_with_sender(message, recipient_user.telegram_id, sender_info))
|
||||||
|
else:
|
||||||
|
# Обычные пользователи видят:
|
||||||
|
# - "Админ" если отправитель - админ
|
||||||
|
# - nickname если отправитель - обычный пользователь
|
||||||
|
if sender_user.telegram_id in ADMIN_IDS:
|
||||||
|
sender_info = "Админ"
|
||||||
|
tasks.append(_send_message_to_user_with_sender(message, recipient_user.telegram_id, sender_info))
|
||||||
|
else:
|
||||||
|
sender_info = sender_user.nickname if sender_user.nickname else (
|
||||||
|
f"@{sender_user.username}" if sender_user.username else sender_user.first_name
|
||||||
|
)
|
||||||
|
tasks.append(_send_message_to_user_with_sender(message, recipient_user.telegram_id, sender_info))
|
||||||
|
|
||||||
|
# Ждем завершения пакета
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
# Обрабатываем результаты
|
||||||
|
for user, result in zip(batch, results):
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
fail_count += 1
|
||||||
|
elif result is not None:
|
||||||
|
forwarded_ids[str(user.telegram_id)] = result
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
fail_count += 1
|
||||||
|
|
||||||
|
# Задержка между пакетами (если есть еще пакеты)
|
||||||
|
if i + BATCH_SIZE < len(users):
|
||||||
|
await asyncio.sleep(BATCH_DELAY)
|
||||||
|
|
||||||
|
logger.info(f"[CHAT] broadcast_message_with_scheduler завершена: успешно={success_count}, ошибок={fail_count}")
|
||||||
|
return forwarded_ids, success_count, fail_count
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_message_to_user(message: Message, user_telegram_id: int) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
Отправить сообщение конкретному пользователю.
|
||||||
|
Возвращает message_id при успехе или None при ошибке.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sent_msg = await message.copy_to(user_telegram_id)
|
||||||
|
return sent_msg.message_id
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to send message to {user_telegram_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_message_to_user_with_sender(message: Message, user_telegram_id: int, sender_info: str) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
Отправить сообщение обычному пользователю с информацией об отправителе.
|
||||||
|
Возвращает message_id при успехе или None при ошибке.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Формируем текст с информацией об отправителе
|
||||||
|
header = f"📨 <b>{sender_info}:</b>\n\n"
|
||||||
|
|
||||||
|
if message.text:
|
||||||
|
# Текстовое сообщение
|
||||||
|
sent_msg = await message.bot.send_message(
|
||||||
|
user_telegram_id,
|
||||||
|
header + message.text,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message.photo:
|
||||||
|
# Фото
|
||||||
|
caption = header + (message.caption or "")
|
||||||
|
sent_msg = await message.bot.send_photo(
|
||||||
|
user_telegram_id,
|
||||||
|
photo=message.photo[-1].file_id,
|
||||||
|
caption=caption,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message.video:
|
||||||
|
# Видео
|
||||||
|
caption = header + (message.caption or "")
|
||||||
|
sent_msg = await message.bot.send_video(
|
||||||
|
user_telegram_id,
|
||||||
|
video=message.video.file_id,
|
||||||
|
caption=caption,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message.document:
|
||||||
|
# Документ
|
||||||
|
caption = header + (message.caption or "")
|
||||||
|
sent_msg = await message.bot.send_document(
|
||||||
|
user_telegram_id,
|
||||||
|
document=message.document.file_id,
|
||||||
|
caption=caption,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message.animation:
|
||||||
|
# GIF
|
||||||
|
caption = header + (message.caption or "")
|
||||||
|
sent_msg = await message.bot.send_animation(
|
||||||
|
user_telegram_id,
|
||||||
|
animation=message.animation.file_id,
|
||||||
|
caption=caption,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message.sticker:
|
||||||
|
# Стикер - сначала отправляем заголовок, потом стикер
|
||||||
|
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
|
||||||
|
sent_msg = await message.bot.send_sticker(user_telegram_id, sticker=message.sticker.file_id)
|
||||||
|
elif message.voice:
|
||||||
|
# Голосовое сообщение
|
||||||
|
sent_msg = await message.bot.send_voice(
|
||||||
|
user_telegram_id,
|
||||||
|
voice=message.voice.file_id,
|
||||||
|
caption=header,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message.video_note:
|
||||||
|
# Видео-кружок
|
||||||
|
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
|
||||||
|
sent_msg = await message.bot.send_video_note(user_telegram_id, video_note=message.video_note.file_id)
|
||||||
|
else:
|
||||||
|
# Неизвестный тип - просто копируем
|
||||||
|
await message.bot.send_message(user_telegram_id, header, parse_mode="HTML")
|
||||||
|
sent_msg = await message.copy_to(user_telegram_id)
|
||||||
|
|
||||||
|
return sent_msg.message_id
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to send message to {user_telegram_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_message_to_admin_with_sender(message: Message, admin_telegram_id: int, sender_info: str) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
Отправить сообщение админу с информацией об отправителе.
|
||||||
|
Возвращает message_id при успехе или None при ошибке.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Формируем текст с информацией об отправителе
|
||||||
|
header = f"📨 <b>Сообщение от {sender_info}:</b>\n\n"
|
||||||
|
|
||||||
|
if message.text:
|
||||||
|
# Текстовое сообщение
|
||||||
|
sent_msg = await message.bot.send_message(
|
||||||
|
admin_telegram_id,
|
||||||
|
header + message.text,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message.photo:
|
||||||
|
# Фото
|
||||||
|
caption = header + (message.caption or "")
|
||||||
|
sent_msg = await message.bot.send_photo(
|
||||||
|
admin_telegram_id,
|
||||||
|
photo=message.photo[-1].file_id,
|
||||||
|
caption=caption,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message.video:
|
||||||
|
# Видео
|
||||||
|
caption = header + (message.caption or "")
|
||||||
|
sent_msg = await message.bot.send_video(
|
||||||
|
admin_telegram_id,
|
||||||
|
video=message.video.file_id,
|
||||||
|
caption=caption,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message.document:
|
||||||
|
# Документ
|
||||||
|
caption = header + (message.caption or "")
|
||||||
|
sent_msg = await message.bot.send_document(
|
||||||
|
admin_telegram_id,
|
||||||
|
document=message.document.file_id,
|
||||||
|
caption=caption,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message.animation:
|
||||||
|
# GIF
|
||||||
|
caption = header + (message.caption or "")
|
||||||
|
sent_msg = await message.bot.send_animation(
|
||||||
|
admin_telegram_id,
|
||||||
|
animation=message.animation.file_id,
|
||||||
|
caption=caption,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message.sticker:
|
||||||
|
# Стикер - сначала отправляем заголовок, потом стикер
|
||||||
|
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
|
||||||
|
sent_msg = await message.bot.send_sticker(admin_telegram_id, sticker=message.sticker.file_id)
|
||||||
|
elif message.voice:
|
||||||
|
# Голосовое сообщение
|
||||||
|
sent_msg = await message.bot.send_voice(
|
||||||
|
admin_telegram_id,
|
||||||
|
voice=message.voice.file_id,
|
||||||
|
caption=header,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
elif message.video_note:
|
||||||
|
# Видео-кружок
|
||||||
|
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
|
||||||
|
sent_msg = await message.bot.send_video_note(admin_telegram_id, video_note=message.video_note.file_id)
|
||||||
|
else:
|
||||||
|
# Неизвестный тип - просто копируем
|
||||||
|
await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML")
|
||||||
|
sent_msg = await message.copy_to(admin_telegram_id)
|
||||||
|
|
||||||
|
return sent_msg.message_id
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to send message with sender info to admin {admin_telegram_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, Optional[int]]:
|
||||||
|
"""Переслать сообщение в канал/группу"""
|
||||||
|
try:
|
||||||
|
# Пересылаем сообщение в канал
|
||||||
|
sent_msg = await message.forward(channel_id)
|
||||||
|
return True, sent_msg.message_id
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to forward message to channel {channel_id}: {e}")
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.photo, StateFilter(ChatStates.in_chat))
|
@router.message(F.photo, StateFilter(ChatStates.in_chat))
|
||||||
async def handle_photo_message(message: Message, state: FSMContext):
|
async def handle_photo_message(message: Message, state: FSMContext):
|
||||||
"""Обработчик фото"""
|
"""Обработчик фото"""
|
||||||
@@ -531,19 +692,11 @@ async def handle_photo_message(message: Message, state: FSMContext):
|
|||||||
photo = message.photo[-1]
|
photo = message.photo[-1]
|
||||||
|
|
||||||
if settings.mode == 'broadcast':
|
if settings.mode == 'broadcast':
|
||||||
# Формируем информацию об отправителе для админов (если это не админ)
|
|
||||||
sender_info = None
|
|
||||||
if not is_admin(message.from_user.id):
|
|
||||||
sender_name = f"@{user.username}" if user.username else user.first_name
|
|
||||||
if user.club_card_number:
|
|
||||||
sender_name += f" (карта: {user.club_card_number})"
|
|
||||||
sender_info = sender_name
|
|
||||||
|
|
||||||
# Рассылаем фото - ВСЕГДА исключаем отправителя
|
# Рассылаем фото - ВСЕГДА исключаем отправителя
|
||||||
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
||||||
message,
|
message,
|
||||||
exclude_user_id=message.from_user.id,
|
sender_user=user,
|
||||||
sender_info=sender_info
|
exclude_user_id=message.from_user.id
|
||||||
)
|
)
|
||||||
|
|
||||||
await ChatMessageService.save_message(
|
await ChatMessageService.save_message(
|
||||||
@@ -605,18 +758,11 @@ async def handle_video_message(message: Message, state: FSMContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if settings.mode == 'broadcast':
|
if settings.mode == 'broadcast':
|
||||||
# Формируем информацию об отправителе для админов (если это не админ)
|
# Рассылаем видео
|
||||||
sender_info = None
|
|
||||||
if not is_admin(message.from_user.id):
|
|
||||||
sender_name = f"@{user.username}" if user.username else user.first_name
|
|
||||||
if user.club_card_number:
|
|
||||||
sender_name += f" (карта: {user.club_card_number})"
|
|
||||||
sender_info = sender_name
|
|
||||||
|
|
||||||
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
||||||
message,
|
message,
|
||||||
exclude_user_id=message.from_user.id,
|
sender_user=user,
|
||||||
sender_info=sender_info
|
exclude_user_id=message.from_user.id
|
||||||
)
|
)
|
||||||
|
|
||||||
await ChatMessageService.save_message(
|
await ChatMessageService.save_message(
|
||||||
@@ -678,18 +824,11 @@ async def handle_document_message(message: Message, state: FSMContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if settings.mode == 'broadcast':
|
if settings.mode == 'broadcast':
|
||||||
# Формируем информацию об отправителе для админов (если это не админ)
|
# Рассылаем документ
|
||||||
sender_info = None
|
|
||||||
if not is_admin(message.from_user.id):
|
|
||||||
sender_name = f"@{user.username}" if user.username else user.first_name
|
|
||||||
if user.club_card_number:
|
|
||||||
sender_name += f" (карта: {user.club_card_number})"
|
|
||||||
sender_info = sender_name
|
|
||||||
|
|
||||||
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
||||||
message,
|
message,
|
||||||
exclude_user_id=message.from_user.id,
|
sender_user=user,
|
||||||
sender_info=sender_info
|
exclude_user_id=message.from_user.id
|
||||||
)
|
)
|
||||||
|
|
||||||
await ChatMessageService.save_message(
|
await ChatMessageService.save_message(
|
||||||
@@ -751,18 +890,11 @@ async def handle_animation_message(message: Message, state: FSMContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if settings.mode == 'broadcast':
|
if settings.mode == 'broadcast':
|
||||||
# Формируем информацию об отправителе для админов (если это не админ)
|
# Рассылаем анимацию
|
||||||
sender_info = None
|
|
||||||
if not is_admin(message.from_user.id):
|
|
||||||
sender_name = f"@{user.username}" if user.username else user.first_name
|
|
||||||
if user.club_card_number:
|
|
||||||
sender_name += f" (карта: {user.club_card_number})"
|
|
||||||
sender_info = sender_name
|
|
||||||
|
|
||||||
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
||||||
message,
|
message,
|
||||||
exclude_user_id=message.from_user.id,
|
sender_user=user,
|
||||||
sender_info=sender_info
|
exclude_user_id=message.from_user.id
|
||||||
)
|
)
|
||||||
|
|
||||||
await ChatMessageService.save_message(
|
await ChatMessageService.save_message(
|
||||||
@@ -824,18 +956,11 @@ async def handle_sticker_message(message: Message, state: FSMContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if settings.mode == 'broadcast':
|
if settings.mode == 'broadcast':
|
||||||
# Формируем информацию об отправителе для админов (если это не админ)
|
# Рассылаем стикер
|
||||||
sender_info = None
|
|
||||||
if not is_admin(message.from_user.id):
|
|
||||||
sender_name = f"@{user.username}" if user.username else user.first_name
|
|
||||||
if user.club_card_number:
|
|
||||||
sender_name += f" (карта: {user.club_card_number})"
|
|
||||||
sender_info = sender_name
|
|
||||||
|
|
||||||
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
forwarded_ids, success, fail = await broadcast_message_with_scheduler(
|
||||||
message,
|
message,
|
||||||
exclude_user_id=message.from_user.id,
|
sender_user=user,
|
||||||
sender_info=sender_info
|
exclude_user_id=message.from_user.id
|
||||||
)
|
)
|
||||||
|
|
||||||
await ChatMessageService.save_message(
|
await ChatMessageService.save_message(
|
||||||
|
|||||||
282
src/handlers/help_handlers.py
Normal file
282
src/handlers/help_handlers.py
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
"""Обработчики справки и помощи пользователям"""
|
||||||
|
from aiogram import Router, F
|
||||||
|
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from src.core.config import ADMIN_IDS
|
||||||
|
|
||||||
|
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin(user_id: int) -> bool:
|
||||||
|
"""Проверка является ли пользователь админом"""
|
||||||
|
return user_id in ADMIN_IDS
|
||||||
|
|
||||||
|
|
||||||
|
router = Router(name='help_router')
|
||||||
|
|
||||||
|
|
||||||
|
def get_help_menu_keyboard() -> InlineKeyboardMarkup:
|
||||||
|
"""Клавиатура меню справки"""
|
||||||
|
buttons = [
|
||||||
|
[InlineKeyboardButton(text="📝 Регистрация", callback_data="help_registration")],
|
||||||
|
[InlineKeyboardButton(text="🎰 Участие в розыгрышах", callback_data="help_lottery")],
|
||||||
|
[InlineKeyboardButton(text="📱 Мои логины", callback_data="help_logins")],
|
||||||
|
[InlineKeyboardButton(text="💬 Чат", callback_data="help_chat")],
|
||||||
|
[InlineKeyboardButton(text="⚙️ Команды", callback_data="help_commands")],
|
||||||
|
[InlineKeyboardButton(text="🏠 Главная", callback_data="back_to_main")]
|
||||||
|
]
|
||||||
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|
||||||
|
|
||||||
|
def get_back_to_help_keyboard() -> InlineKeyboardMarkup:
|
||||||
|
"""Клавиатура возврата к справке"""
|
||||||
|
buttons = [
|
||||||
|
[InlineKeyboardButton(text="◀️ Назад к справке", callback_data="help_main")],
|
||||||
|
[InlineKeyboardButton(text="🏠 Главная", callback_data="back_to_main")]
|
||||||
|
]
|
||||||
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(CaseInsensitiveCommand("help"))
|
||||||
|
async def help_command(message: Message):
|
||||||
|
"""Показать справку по команде /help (регистронезависимо)"""
|
||||||
|
await show_help_main(message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "help_main")
|
||||||
|
async def help_main_callback(callback: CallbackQuery):
|
||||||
|
"""Показать главное меню справки"""
|
||||||
|
await callback.answer()
|
||||||
|
await show_help_main(callback.message, edit=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def show_help_main(message: Message, edit: bool = False):
|
||||||
|
"""Показать главное меню справки"""
|
||||||
|
text = (
|
||||||
|
"❓ <b>Справка по работе с ботом</b>\n\n"
|
||||||
|
"Выберите интересующий вас раздел:\n\n"
|
||||||
|
"📝 <b>Регистрация</b> - как зарегистрироваться в системе\n"
|
||||||
|
"🎰 <b>Участие в розыгрышах</b> - как участвовать и выигрывать\n"
|
||||||
|
"📱 <b>Мои логины</b> - информация о ваших добавленных логинах\n"
|
||||||
|
"💬 <b>Чат</b> - общение с другими участниками\n"
|
||||||
|
"⚙️ <b>Команды</b> - список доступных команд"
|
||||||
|
)
|
||||||
|
|
||||||
|
keyboard = get_help_menu_keyboard()
|
||||||
|
|
||||||
|
if edit:
|
||||||
|
try:
|
||||||
|
await message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
|
||||||
|
except:
|
||||||
|
await message.answer(text, reply_markup=keyboard, parse_mode="HTML")
|
||||||
|
else:
|
||||||
|
await message.answer(text, reply_markup=keyboard, parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "help_registration")
|
||||||
|
async def help_registration(callback: CallbackQuery):
|
||||||
|
"""Справка по регистрации"""
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
text = (
|
||||||
|
"📝 <b>Регистрация в системе</b>\n\n"
|
||||||
|
"<b>Как зарегистрироваться:</b>\n\n"
|
||||||
|
"1️⃣ Откройте главное меню и выберите <i>\"Регистрация\"</i>\n\n"
|
||||||
|
"2️⃣ Введите ваши данные:\n"
|
||||||
|
" • Имя и фамилию\n"
|
||||||
|
" • Номер телефона\n"
|
||||||
|
" • Номер клубной карты (если есть)\n\n"
|
||||||
|
"3️⃣ Ожидайте подтверждения от администратора\n\n"
|
||||||
|
"4️⃣ После одобрения вам станут доступны все функции бота:\n"
|
||||||
|
" ✅ Участие в розыгрышах\n"
|
||||||
|
" ✅ Доступ к чату\n"
|
||||||
|
" ✅ Получение уведомлений\n\n"
|
||||||
|
"💡 <b>Важно!</b>\n"
|
||||||
|
"Указывайте корректные данные - они проверяются администратором.\n\n"
|
||||||
|
"Статус вашей регистрации можно проверить в главном меню."
|
||||||
|
)
|
||||||
|
|
||||||
|
keyboard = get_back_to_help_keyboard()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
|
||||||
|
except:
|
||||||
|
await callback.message.answer(text, reply_markup=keyboard, parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "help_lottery")
|
||||||
|
async def help_lottery(callback: CallbackQuery):
|
||||||
|
"""Справка по розыгрышам"""
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
text = (
|
||||||
|
"🎰 <b>Участие в розыгрышах</b>\n\n"
|
||||||
|
"<b>Как принять участие:</b>\n\n"
|
||||||
|
"1️⃣ Убедитесь, что вы зарегистрированы\n\n"
|
||||||
|
"2️⃣ Дождитесь объявления нового розыгрыша\n"
|
||||||
|
" • Уведомления приходят всем участникам\n"
|
||||||
|
" • Розыгрыши проводятся регулярно\n\n"
|
||||||
|
"3️⃣ В описании розыгрыша будет указано:\n"
|
||||||
|
" 📝 Название и описание приза\n"
|
||||||
|
" 👥 Количество победителей\n"
|
||||||
|
" 📅 Дата и время проведения\n\n"
|
||||||
|
"4️⃣ Когда придет время:\n"
|
||||||
|
" • Администратор проведет розыгрыш\n"
|
||||||
|
" • Победители определяются случайным образом\n"
|
||||||
|
" • Всем участникам придет уведомление о результатах\n\n"
|
||||||
|
"🏆 <b>Если вы выиграли:</b>\n"
|
||||||
|
" • Вы получите личное уведомление\n"
|
||||||
|
" • Информация о получении приза будет в сообщении\n"
|
||||||
|
" • Следуйте инструкциям администратора\n\n"
|
||||||
|
"💡 <b>Совет:</b> Включите уведомления бота, чтобы не пропустить розыгрыш!"
|
||||||
|
)
|
||||||
|
|
||||||
|
keyboard = get_back_to_help_keyboard()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
|
||||||
|
except:
|
||||||
|
await callback.message.answer(text, reply_markup=keyboard, parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "help_logins")
|
||||||
|
async def help_logins(callback: CallbackQuery):
|
||||||
|
"""Справка по логинам пользователей"""
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
text = (
|
||||||
|
"📱 <b>Мои логины</b>\n\n"
|
||||||
|
"<b>Что это такое?</b>\n\n"
|
||||||
|
"В этом разделе вы всегда сможете найти свои добавленные логины в розыгрыши, "
|
||||||
|
"которые администратор указал для вас в системе.\n\n"
|
||||||
|
|
||||||
|
"<b>Какая информация показывается?</b>\n\n"
|
||||||
|
"Для каждого логина вы сможете увидеть:\n"
|
||||||
|
"🎲 <b>Активные розыгрыши</b> - розыгрыши в которых сейчас участвует логин\n"
|
||||||
|
"🏁 <b>Завершенные розыгрыши</b> - прошедшие розыгрыши:\n"
|
||||||
|
" 🏆 ВЫИГРАЛ - если логин победил (указано место)\n"
|
||||||
|
" ✗ Не выиграл - если логин не получил приз\n\n"
|
||||||
|
|
||||||
|
"⚠️ <b>Важное уточнение о статусе логинов:</b>\n\n"
|
||||||
|
"✅ <b>Зеленый (активный)</b> - логин участвует в новых розыгрышах\n"
|
||||||
|
"⏸️ <b>Серый (неактивный)</b> - логин не участвует в новых розыгрышах\n\n"
|
||||||
|
|
||||||
|
"Имейте в виду, что логины, которые участвовали в закрытых розыгрышах, "
|
||||||
|
"<b>не добавляются в новые розыгрыши</b>. В списке отображаются только те логины, "
|
||||||
|
"которые активны и соответствуют условиям текущих розыгрышей.\n\n"
|
||||||
|
|
||||||
|
"<b>Как это работает:</b>\n\n"
|
||||||
|
"1️⃣ Если у вас есть 100 логинов\n"
|
||||||
|
"2️⃣ 60 из них участвовали в прошедших/закрытых розыгрышах\n"
|
||||||
|
"3️⃣ Статус этих 60 логинов будет ⏸️ (неактивны)\n"
|
||||||
|
"4️⃣ Они не добавляются в новые розыгрыши\n"
|
||||||
|
"5️⃣ В новых розыгрышах участвуют только оставшиеся активные логины\n\n"
|
||||||
|
|
||||||
|
"<b>Как использовать:</b>\n\n"
|
||||||
|
"1️⃣ Откройте главное меню\n"
|
||||||
|
"2️⃣ Нажмите кнопку <i>\"Мои логины\"</i>\n"
|
||||||
|
"3️⃣ Вы увидите полный список всех ваших логинов с информацией\n\n"
|
||||||
|
|
||||||
|
"💡 <b>Совет:</b>\n"
|
||||||
|
"Если вы не видите ожидаемый логин в списке активных розыгрышей, "
|
||||||
|
"это может означать, что он уже участвовал в закрытых розыгрышах и помечен как неактивный. "
|
||||||
|
"Свяжитесь с администратором для уточнения.\n\n"
|
||||||
|
|
||||||
|
"🔄 <b>Обновление информации:</b>\n"
|
||||||
|
"Список обновляется автоматически при каждом открытии раздела."
|
||||||
|
)
|
||||||
|
|
||||||
|
keyboard = get_back_to_help_keyboard()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
|
||||||
|
except:
|
||||||
|
await callback.message.answer(text, reply_markup=keyboard, parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "help_chat")
|
||||||
|
async def help_chat(callback: CallbackQuery):
|
||||||
|
"""Справка по чату"""
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
text = (
|
||||||
|
"💬 <b>Чат участников</b>\n\n"
|
||||||
|
"<b>Как пользоваться чатом:</b>\n\n"
|
||||||
|
"1️⃣ <b>Вход в чат:</b>\n"
|
||||||
|
" • Откройте главное меню\n"
|
||||||
|
" • Выберите <i>\"Войти в чат\"</i>\n"
|
||||||
|
" • Или отправьте команду <code>/chat</code>\n\n"
|
||||||
|
"2️⃣ <b>Отправка сообщений:</b>\n"
|
||||||
|
" • Пишите как обычно в Telegram\n"
|
||||||
|
" • Ваши сообщения увидят все участники\n"
|
||||||
|
" • Можно отправлять:\n"
|
||||||
|
" 📝 Текст\n"
|
||||||
|
" 🖼 Фото и видео\n"
|
||||||
|
" 📎 Документы\n"
|
||||||
|
" 😊 Стикеры\n\n"
|
||||||
|
"3️⃣ <b>Выход из чата:</b>\n"
|
||||||
|
" • Нажмите кнопку <i>\"Выйти из чата\"</i>\n"
|
||||||
|
" • Или отправьте команду <code>/exit</code>\n"
|
||||||
|
" • Или напишите <code>старт</code> / <code>start</code> / <code>/start</code>\n\n"
|
||||||
|
"⚠️ <b>Правила чата:</b>\n"
|
||||||
|
" • Будьте вежливы с другими участниками\n"
|
||||||
|
" • Не спамьте сообщениями\n"
|
||||||
|
" • Запрещены оскорбления и реклама\n"
|
||||||
|
" • Администратор может заблокировать за нарушения\n\n"
|
||||||
|
"💡 <b>Подсказка:</b>\n"
|
||||||
|
"Если вы отправляете 20+ сообщений, они рассылаются пакетами с небольшой задержкой."
|
||||||
|
)
|
||||||
|
|
||||||
|
keyboard = get_back_to_help_keyboard()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
|
||||||
|
except:
|
||||||
|
await callback.message.answer(text, reply_markup=keyboard, parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "help_commands")
|
||||||
|
async def help_commands(callback: CallbackQuery):
|
||||||
|
"""Справка по командам"""
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
user_id = callback.from_user.id
|
||||||
|
is_user_admin = is_admin(user_id)
|
||||||
|
|
||||||
|
text = (
|
||||||
|
"⚙️ <b>Список команд бота</b>\n\n"
|
||||||
|
"<b>Основные команды:</b>\n\n"
|
||||||
|
"🏠 <code>/start</code> - Главное меню\n"
|
||||||
|
"❓ <code>/help</code> - Справка (это меню)\n"
|
||||||
|
"💬 <code>/chat</code> - Войти в чат\n"
|
||||||
|
"🚪 <code>/exit</code> - Выйти из чата\n\n"
|
||||||
|
"<b>Как использовать:</b>\n\n"
|
||||||
|
"• Отправьте команду в чат с ботом\n"
|
||||||
|
"• Начните команду с символа /\n"
|
||||||
|
"• Можно также использовать кнопки в меню\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_user_admin:
|
||||||
|
text += (
|
||||||
|
"👑 <b>Команды администратора:</b>\n\n"
|
||||||
|
"🔧 <code>/admin</code> - Панель администратора\n"
|
||||||
|
"📊 Управление розыгрышами\n"
|
||||||
|
"👥 Управление пользователями\n"
|
||||||
|
"📢 Массовые рассылки\n"
|
||||||
|
"⚙️ Настройки системы\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
text += (
|
||||||
|
"💡 <b>Полезные советы:</b>\n\n"
|
||||||
|
"• Включите уведомления для получения важных сообщений\n"
|
||||||
|
"• Используйте кнопки - это быстрее команд\n"
|
||||||
|
"• В чате пишите <code>старт</code> чтобы вернуться в меню\n"
|
||||||
|
"• Регулярно проверяйте бота на наличие новых розыгрышей"
|
||||||
|
)
|
||||||
|
|
||||||
|
keyboard = get_back_to_help_keyboard()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
|
||||||
|
except:
|
||||||
|
await callback.message.answer(text, reply_markup=keyboard, parse_mode="HTML")
|
||||||
@@ -6,6 +6,7 @@ from aiogram import Router, F, Bot
|
|||||||
from aiogram.types import Message, CallbackQuery
|
from aiogram.types import Message, CallbackQuery
|
||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
|
|
||||||
|
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||||
from ..core.config import ADMIN_IDS
|
from ..core.config import ADMIN_IDS
|
||||||
from ..core.database import async_session_maker
|
from ..core.database import async_session_maker
|
||||||
from ..core.chat_services import ChatMessageService
|
from ..core.chat_services import ChatMessageService
|
||||||
@@ -21,10 +22,10 @@ def is_admin(user_id: int) -> bool:
|
|||||||
return user_id in ADMIN_IDS
|
return user_id in ADMIN_IDS
|
||||||
|
|
||||||
|
|
||||||
@message_admin_router.message(Command("delete"))
|
@message_admin_router.message(CaseInsensitiveCommand("delete"))
|
||||||
async def delete_replied_message(message: Message):
|
async def delete_replied_message(message: Message):
|
||||||
"""
|
"""
|
||||||
Удаление сообщения по команде /delete
|
Удаление сообщения по команде /delete (регистронезависимо)
|
||||||
Работает только если команда является ответом на сообщение бота
|
Работает только если команда является ответом на сообщение бота
|
||||||
"""
|
"""
|
||||||
if not is_admin(message.from_user.id):
|
if not is_admin(message.from_user.id):
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.filters import Command, StateFilter
|
from aiogram.filters import Command, StateFilter
|
||||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
|
||||||
|
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.fsm.state import State, StatesGroup
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -28,10 +30,39 @@ def is_admin(user_id: int) -> bool:
|
|||||||
return user_id in ADMIN_IDS
|
return user_id in ADMIN_IDS
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("chat"))
|
def format_sender_name(user: User, is_current_user: bool = False, current_user_is_admin: bool = False) -> str:
|
||||||
|
"""
|
||||||
|
Форматирует имя отправителя для отображения в чате
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: Объект пользователя
|
||||||
|
is_current_user: Текущий ли это пользователь
|
||||||
|
current_user_is_admin: Админ ли текущий пользователь
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Отформатированное имя
|
||||||
|
"""
|
||||||
|
if is_current_user:
|
||||||
|
return "🔵 Вы"
|
||||||
|
|
||||||
|
# Если это администратор и текущий пользователь не админ - показываем "Админ"
|
||||||
|
if user.is_admin and not current_user_is_admin:
|
||||||
|
return "🔵 Админ"
|
||||||
|
|
||||||
|
# Формируем базовое имя (используем nickname из профиля)
|
||||||
|
name = user.nickname or user.first_name or f"@{user.username}" or "Unknown"
|
||||||
|
|
||||||
|
# Добавляем информацию о карте если пользователь админ и текущий юзер админ
|
||||||
|
if current_user_is_admin and user.club_card_number:
|
||||||
|
name += f" (карта: {user.club_card_number})"
|
||||||
|
|
||||||
|
return f"🔵 {name}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(CaseInsensitiveCommand("chat"))
|
||||||
async def show_chat_menu(message: Message, state: FSMContext):
|
async def show_chat_menu(message: Message, state: FSMContext):
|
||||||
"""
|
"""
|
||||||
Главное меню чата
|
Главное меню чата (регистронезависимо)
|
||||||
/chat - показать меню с опциями общения
|
/chat - показать меню с опциями общения
|
||||||
"""
|
"""
|
||||||
# Очищаем состояние при входе в меню (выход из диалога)
|
# Очищаем состояние при входе в меню (выход из диалога)
|
||||||
@@ -104,7 +135,7 @@ async def select_recipient(callback: CallbackQuery, state: FSMContext):
|
|||||||
# Создаём кнопки с пользователями (по 1 на строку)
|
# Создаём кнопки с пользователями (по 1 на строку)
|
||||||
buttons = []
|
buttons = []
|
||||||
for user in users[:20]: # Ограничение 20 пользователей на странице
|
for user in users[:20]: # Ограничение 20 пользователей на странице
|
||||||
display_name = f"@{user.username}" if user.username else user.first_name
|
display_name = user.nickname or f"@{user.username}" or user.first_name or "Unknown"
|
||||||
if user.club_card_number:
|
if user.club_card_number:
|
||||||
display_name += f" (карта: {user.club_card_number})"
|
display_name += f" (карта: {user.club_card_number})"
|
||||||
|
|
||||||
@@ -160,14 +191,18 @@ async def start_conversation(callback: CallbackQuery, state: FSMContext):
|
|||||||
await state.update_data(recipient_id=recipient.id, recipient_telegram_id=recipient.telegram_id)
|
await state.update_data(recipient_id=recipient.id, recipient_telegram_id=recipient.telegram_id)
|
||||||
await state.set_state(P2PChatStates.chatting)
|
await state.set_state(P2PChatStates.chatting)
|
||||||
|
|
||||||
recipient_name = f"@{recipient.username}" if recipient.username else recipient.first_name
|
recipient_name = recipient.nickname or f"@{recipient.username}" or recipient.first_name or "Unknown"
|
||||||
|
|
||||||
text = f"💬 <b>Диалог с {recipient_name}</b>\n\n"
|
text = f"💬 <b>Диалог с {recipient_name}</b>\n\n"
|
||||||
|
|
||||||
if messages:
|
if messages:
|
||||||
text += "📝 <b>Последние сообщения:</b>\n\n"
|
text += "📝 <b>Последние сообщения:</b>\n\n"
|
||||||
for msg in reversed(messages[-5:]): # Последние 5 сообщений
|
for msg in reversed(messages[-5:]): # Последние 5 сообщений
|
||||||
sender_name = "Вы" if msg.sender_id == sender.id else recipient_name
|
# Определяем имя отправителя
|
||||||
|
is_current = msg.sender_id == sender.id
|
||||||
|
user_for_display = sender if is_current else recipient
|
||||||
|
sender_name = format_sender_name(user_for_display, is_current, is_admin(sender.telegram_id))
|
||||||
|
|
||||||
msg_text = msg.text[:50] + "..." if msg.text and len(msg.text) > 50 else (msg.text or f"[{msg.message_type}]")
|
msg_text = msg.text[:50] + "..." if msg.text and len(msg.text) > 50 else (msg.text or f"[{msg.message_type}]")
|
||||||
text += f"• {sender_name}: {msg_text}\n"
|
text += f"• {sender_name}: {msg_text}\n"
|
||||||
text += "\n"
|
text += "\n"
|
||||||
@@ -202,7 +237,7 @@ async def show_conversations(callback: CallbackQuery):
|
|||||||
last_name=callback.from_user.last_name
|
last_name=callback.from_user.last_name
|
||||||
)
|
)
|
||||||
|
|
||||||
conversations = await P2PMessageService.get_recent_conversations(session, user.id, limit=10)
|
conversations = await P2PMessageService.get_recent_conversations(session, sender.id, limit=10)
|
||||||
|
|
||||||
if not conversations:
|
if not conversations:
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
@@ -215,7 +250,7 @@ async def show_conversations(callback: CallbackQuery):
|
|||||||
|
|
||||||
buttons = []
|
buttons = []
|
||||||
for peer, last_msg, unread in conversations:
|
for peer, last_msg, unread in conversations:
|
||||||
peer_name = f"@{peer.username}" if peer.username else peer.first_name
|
peer_name = peer.nickname or f"@{peer.username}" or peer.first_name or "Unknown"
|
||||||
|
|
||||||
# Иконка в зависимости от непрочитанных
|
# Иконка в зависимости от непрочитанных
|
||||||
icon = "🔴" if unread > 0 else "💬"
|
icon = "🔴" if unread > 0 else "💬"
|
||||||
@@ -232,7 +267,11 @@ async def show_conversations(callback: CallbackQuery):
|
|||||||
callback_data=f"p2p:user:{peer.id}"
|
callback_data=f"p2p:user:{peer.id}"
|
||||||
)])
|
)])
|
||||||
|
|
||||||
text += f"{icon} <b>{peer_name}</b>\n"
|
text += f"{icon} <b>{peer_name}</b>"
|
||||||
|
# Показываем номер карты если есть
|
||||||
|
if peer.club_card_number:
|
||||||
|
text += f" (карта: {peer.club_card_number})"
|
||||||
|
text += "\n"
|
||||||
text += f" {preview}\n"
|
text += f" {preview}\n"
|
||||||
if unread > 0:
|
if unread > 0:
|
||||||
text += f" 📨 Непрочитанных: {unread}\n"
|
text += f" 📨 Непрочитанных: {unread}\n"
|
||||||
@@ -266,12 +305,53 @@ async def end_conversation(callback: CallbackQuery, state: FSMContext):
|
|||||||
async def back_to_menu(callback: CallbackQuery, state: FSMContext):
|
async def back_to_menu(callback: CallbackQuery, state: FSMContext):
|
||||||
"""Вернуться в главное меню"""
|
"""Вернуться в главное меню"""
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
# Имитируем команду /chat
|
async with async_session_maker() as session:
|
||||||
fake_message = callback.message
|
user = await UserService.get_or_create_user(
|
||||||
fake_message.from_user = callback.from_user
|
session,
|
||||||
|
callback.from_user.id,
|
||||||
|
username=callback.from_user.username,
|
||||||
|
first_name=callback.from_user.first_name,
|
||||||
|
last_name=callback.from_user.last_name
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
await callback.message.edit_text("❌ Вы не зарегистрированы. Используйте /start")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Получаем количество непрочитанных сообщений
|
||||||
|
unread_count = await P2PMessageService.get_unread_count(session, user.id)
|
||||||
|
|
||||||
|
# Получаем последние диалоги
|
||||||
|
recent = await P2PMessageService.get_recent_conversations(session, user.id, limit=5)
|
||||||
|
|
||||||
await show_chat_menu(fake_message, state)
|
text = "💬 <b>Чат</b>\n\n"
|
||||||
|
|
||||||
|
if unread_count > 0:
|
||||||
|
text += f"📨 У вас <b>{unread_count}</b> непрочитанных сообщений\n\n"
|
||||||
|
|
||||||
|
text += "Выберите действие:"
|
||||||
|
|
||||||
|
buttons = [
|
||||||
|
[InlineKeyboardButton(
|
||||||
|
text="✉️ Написать пользователю",
|
||||||
|
callback_data="p2p:select_user"
|
||||||
|
)],
|
||||||
|
[InlineKeyboardButton(
|
||||||
|
text="📋 Мои диалоги",
|
||||||
|
callback_data="p2p:my_conversations"
|
||||||
|
)]
|
||||||
|
]
|
||||||
|
|
||||||
|
if recent:
|
||||||
|
text += "\n\n<b>Последние диалоги:</b>\n"
|
||||||
|
for peer, last_msg, unread in recent:
|
||||||
|
unread_badge = f" ({unread})" if unread > 0 else ""
|
||||||
|
text += f" • @{peer.username or peer.first_name}{unread_badge}\n"
|
||||||
|
|
||||||
|
kb = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
await callback.message.edit_text(text, reply_markup=kb, parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
# Обработчик сообщений в состоянии chatting
|
# Обработчик сообщений в состоянии chatting
|
||||||
@@ -299,7 +379,19 @@ async def handle_p2p_message(message: Message, state: FSMContext):
|
|||||||
first_name=message.from_user.first_name,
|
first_name=message.from_user.first_name,
|
||||||
last_name=message.from_user.last_name
|
last_name=message.from_user.last_name
|
||||||
)
|
)
|
||||||
sender_name = f"@{sender.username}" if sender.username else sender.first_name
|
|
||||||
|
# Получаем информацию о получателе для определения как подписать сообщение
|
||||||
|
recipient = await UserService.get_user_by_telegram_id(session, recipient_telegram_id)
|
||||||
|
|
||||||
|
# Формируем подпись сообщения для получателя
|
||||||
|
if sender.is_admin:
|
||||||
|
sender_name = "АДМИН"
|
||||||
|
else:
|
||||||
|
sender_name = sender.nickname or f"@{sender.username}" or sender.first_name or "Unknown"
|
||||||
|
|
||||||
|
# Добавляем карту если получатель админ
|
||||||
|
if recipient and recipient.is_admin and sender.club_card_number:
|
||||||
|
sender_name += f" (карта: {sender.club_card_number})"
|
||||||
|
|
||||||
# Определяем тип сообщения
|
# Определяем тип сообщения
|
||||||
message_type = "text"
|
message_type = "text"
|
||||||
@@ -324,28 +416,28 @@ async def handle_p2p_message(message: Message, state: FSMContext):
|
|||||||
if message_type == "text":
|
if message_type == "text":
|
||||||
sent = await message.bot.send_message(
|
sent = await message.bot.send_message(
|
||||||
recipient_telegram_id,
|
recipient_telegram_id,
|
||||||
f"💬 <b>Сообщение от {sender_name}:</b>\n\n{text}",
|
f"<b>{sender_name}</b>\n\n{text}",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
elif message_type == "photo":
|
elif message_type == "photo":
|
||||||
sent = await message.bot.send_photo(
|
sent = await message.bot.send_photo(
|
||||||
recipient_telegram_id,
|
recipient_telegram_id,
|
||||||
photo=file_id,
|
photo=file_id,
|
||||||
caption=f"💬 <b>Фото от {sender_name}</b>\n\n{text or ''}" ,
|
caption=f"<b>{sender_name}</b>\n\n{text or ''}" ,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
elif message_type == "video":
|
elif message_type == "video":
|
||||||
sent = await message.bot.send_video(
|
sent = await message.bot.send_video(
|
||||||
recipient_telegram_id,
|
recipient_telegram_id,
|
||||||
video=file_id,
|
video=file_id,
|
||||||
caption=f"💬 <b>Видео от {sender_name}</b>\n\n{text or ''}",
|
caption=f"<b>{sender_name}</b>\n\n{text or ''}",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
elif message_type == "document":
|
elif message_type == "document":
|
||||||
sent = await message.bot.send_document(
|
sent = await message.bot.send_document(
|
||||||
recipient_telegram_id,
|
recipient_telegram_id,
|
||||||
document=file_id,
|
document=file_id,
|
||||||
caption=f"💬 <b>Документ от {sender_name}</b>\n\n{text or ''}",
|
caption=f"<b>{sender_name}</b>\n\n{text or ''}",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from sqlalchemy import select, and_
|
|||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
import random
|
import random
|
||||||
|
|
||||||
|
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||||
from src.core.database import async_session_maker
|
from src.core.database import async_session_maker
|
||||||
from src.core.registration_services import AccountService, WinnerNotificationService
|
from src.core.registration_services import AccountService, WinnerNotificationService
|
||||||
from src.core.services import LotteryService
|
from src.core.services import LotteryService
|
||||||
@@ -17,11 +18,11 @@ from src.core.permissions import admin_only
|
|||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("check_unclaimed"))
|
@router.message(CaseInsensitiveCommand("check_unclaimed"))
|
||||||
@admin_only
|
@admin_only
|
||||||
async def check_unclaimed_winners(message: Message):
|
async def check_unclaimed_winners(message: Message):
|
||||||
"""
|
"""
|
||||||
Проверить неподтвержденные выигрыши (более 24 часов)
|
Проверить неподтвержденные выигрыши (более 24 часов) (регистронезависимо)
|
||||||
Формат: /check_unclaimed <lottery_id>
|
Формат: /check_unclaimed <lottery_id>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -118,11 +119,11 @@ async def check_unclaimed_winners(message: Message):
|
|||||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("redraw"))
|
@router.message(CaseInsensitiveCommand("redraw"))
|
||||||
@admin_only
|
@admin_only
|
||||||
async def redraw_lottery(message: Message):
|
async def redraw_lottery(message: Message):
|
||||||
"""
|
"""
|
||||||
Переиграть розыгрыш для неподтвержденных выигрышей
|
Переиграть розыгрыш для неподтвержденных выигрышей (регистронезависимо)
|
||||||
Формат: /redraw <lottery_id>
|
Формат: /redraw <lottery_id>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -356,9 +357,29 @@ async def confirm_winner_callback(callback_query):
|
|||||||
winner.claimed_at = datetime.now(timezone.utc)
|
winner.claimed_at = datetime.now(timezone.utc)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
# Получаем данные о розыгрыше
|
# Получаем данные о розыгрыше и пользователе
|
||||||
lottery = await LotteryService.get_lottery(session, winner.lottery_id)
|
lottery = await LotteryService.get_lottery(session, winner.lottery_id)
|
||||||
|
|
||||||
|
# Получаем информацию о пользователе
|
||||||
|
owner = None
|
||||||
|
if winner.account_number:
|
||||||
|
owner = await AccountService.get_account_owner(session, winner.account_number)
|
||||||
|
elif winner.user_id:
|
||||||
|
user_result = await session.execute(
|
||||||
|
select(User).where(User.id == winner.user_id)
|
||||||
|
)
|
||||||
|
owner = user_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# Формируем отображаемое имя
|
||||||
|
display_name = "Пользователь"
|
||||||
|
if owner:
|
||||||
|
if owner.nickname:
|
||||||
|
display_name = owner.nickname
|
||||||
|
elif owner.username:
|
||||||
|
display_name = f"@{owner.username}"
|
||||||
|
elif owner.first_name:
|
||||||
|
display_name = owner.first_name
|
||||||
|
|
||||||
# Отправляем подтверждение пользователю
|
# Отправляем подтверждение пользователю
|
||||||
confirmation_text = (
|
confirmation_text = (
|
||||||
f"✅ **Выигрыш подтвержден!**\n\n"
|
f"✅ **Выигрыш подтвержден!**\n\n"
|
||||||
@@ -375,13 +396,17 @@ async def confirm_winner_callback(callback_query):
|
|||||||
parse_mode="Markdown"
|
parse_mode="Markdown"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Уведомляем админов
|
# Уведомляем админов с nickname и клубной картой
|
||||||
for admin_id in ADMIN_IDS:
|
for admin_id in ADMIN_IDS:
|
||||||
try:
|
try:
|
||||||
|
# Формируем информацию для админа
|
||||||
|
user_info = display_name
|
||||||
|
if owner and owner.club_card_number:
|
||||||
|
user_info = f"{display_name} (карта: {owner.club_card_number})"
|
||||||
|
|
||||||
admin_text = (
|
admin_text = (
|
||||||
f"✅ **Подтверждение выигрыша**\n\n"
|
f"✅ **Подтверждение выигрыша**\n\n"
|
||||||
f"👤 Пользователь: {callback_query.from_user.full_name} "
|
f"👤 Пользователь: {user_info}\n"
|
||||||
f"(@{callback_query.from_user.username or 'нет username'})\n"
|
|
||||||
f"🎯 Розыгрыш: {lottery.title}\n"
|
f"🎯 Розыгрыш: {lottery.title}\n"
|
||||||
f"🏆 Место: {winner.place}\n"
|
f"🏆 Место: {winner.place}\n"
|
||||||
f"🎁 Приз: {winner.prize}\n"
|
f"🎁 Приз: {winner.prize}\n"
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
from aiogram.filters import Command, StateFilter
|
from aiogram.filters import Command, StateFilter
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.fsm.state import State, StatesGroup
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
import logging
|
import logging
|
||||||
@@ -9,13 +13,55 @@ import logging
|
|||||||
from src.core.database import async_session_maker
|
from src.core.database import async_session_maker
|
||||||
from src.core.registration_services import RegistrationService, AccountService
|
from src.core.registration_services import RegistrationService, AccountService
|
||||||
from src.core.services import UserService
|
from src.core.services import UserService
|
||||||
|
from src.core.models import Participation, Winner, Lottery
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
# Служебные слова, которые нельзя использовать как никнейм
|
||||||
|
FORBIDDEN_NICKNAMES = [
|
||||||
|
'привет', 'здравствуйте', 'добрый', 'день', 'вечер', 'утро',
|
||||||
|
'спасибо', 'пожалуйста', 'извините', 'до свидания', 'пока',
|
||||||
|
'admin', 'administrator', 'moderator', 'bot', 'system',
|
||||||
|
'hello', 'hi', 'thanks', 'please', 'sorry', 'goodbye', 'bye'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_nickname(nickname: str) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Валидация никнейма
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(valid, error_message)
|
||||||
|
"""
|
||||||
|
nickname = nickname.strip()
|
||||||
|
|
||||||
|
# Проверка длины
|
||||||
|
if len(nickname) < 2:
|
||||||
|
return False, "❌ Никнейм слишком короткий (минимум 2 символа)"
|
||||||
|
|
||||||
|
if len(nickname) > 20:
|
||||||
|
return False, "❌ Никнейм слишком длинный (максимум 20 символов)"
|
||||||
|
|
||||||
|
# Проверка на служебные слова
|
||||||
|
nickname_lower = nickname.lower()
|
||||||
|
for forbidden in FORBIDDEN_NICKNAMES:
|
||||||
|
if forbidden in nickname_lower:
|
||||||
|
import random
|
||||||
|
suggestion = f"{nickname[:3]}{random.randint(10, 99)}"
|
||||||
|
return False, f"❌ Это похоже на приветствие или служебное слово.\n\nПридумайте уникальный никнейм (например: {suggestion})"
|
||||||
|
|
||||||
|
# Проверка на команды
|
||||||
|
if nickname.startswith('/'):
|
||||||
|
return False, "❌ Никнейм не может начинаться с '/'"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
class RegistrationStates(StatesGroup):
|
class RegistrationStates(StatesGroup):
|
||||||
"""Состояния для процесса регистрации"""
|
"""Состояния для процесса регистрации"""
|
||||||
|
waiting_for_nickname = State()
|
||||||
waiting_for_club_card = State()
|
waiting_for_club_card = State()
|
||||||
waiting_for_phone = State()
|
waiting_for_phone = State()
|
||||||
|
|
||||||
@@ -28,7 +74,11 @@ async def start_registration(callback: CallbackQuery, state: FSMContext):
|
|||||||
text = (
|
text = (
|
||||||
"📝 Регистрация в системе\n\n"
|
"📝 Регистрация в системе\n\n"
|
||||||
"Для участия в розыгрышах необходимо зарегистрироваться.\n\n"
|
"Для участия в розыгрышах необходимо зарегистрироваться.\n\n"
|
||||||
"Введите номер вашей клубной карты:"
|
"Шаг 1 из 3: Придумайте никнейм\n\n"
|
||||||
|
"🎭 Введите ваш никнейм для чата:\n"
|
||||||
|
"• От 2 до 20 символов\n"
|
||||||
|
"• Может содержать буквы, цифры, пробелы\n"
|
||||||
|
"• Это имя будут видеть другие участники"
|
||||||
)
|
)
|
||||||
|
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
@@ -37,6 +87,32 @@ async def start_registration(callback: CallbackQuery, state: FSMContext):
|
|||||||
[InlineKeyboardButton(text="❌ Отмена", callback_data="back_to_main")]
|
[InlineKeyboardButton(text="❌ Отмена", callback_data="back_to_main")]
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
|
await state.set_state(RegistrationStates.waiting_for_nickname)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(StateFilter(RegistrationStates.waiting_for_nickname))
|
||||||
|
async def process_nickname(message: Message, state: FSMContext):
|
||||||
|
"""Обработка никнейма"""
|
||||||
|
nickname = message.text.strip()
|
||||||
|
|
||||||
|
# Валидация никнейма
|
||||||
|
valid, error_msg = validate_nickname(nickname)
|
||||||
|
|
||||||
|
if not valid:
|
||||||
|
await message.answer(
|
||||||
|
f"{error_msg}\n\n"
|
||||||
|
"Попробуйте другой вариант:"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Сохраняем никнейм
|
||||||
|
await state.update_data(nickname=nickname)
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
f"✅ Отлично! Ваш никнейм: {nickname}\n\n"
|
||||||
|
"Шаг 2 из 3: Клубная карта\n\n"
|
||||||
|
"📝 Введите номер вашей клубной карты:"
|
||||||
|
)
|
||||||
await state.set_state(RegistrationStates.waiting_for_club_card)
|
await state.set_state(RegistrationStates.waiting_for_club_card)
|
||||||
|
|
||||||
|
|
||||||
@@ -60,7 +136,8 @@ async def process_club_card(message: Message, state: FSMContext):
|
|||||||
await state.update_data(club_card_number=club_card_number)
|
await state.update_data(club_card_number=club_card_number)
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"📱 Теперь введите ваш номер телефона\n"
|
"Шаг 3 из 3: Телефон\n\n"
|
||||||
|
"📱 Введите ваш номер телефона\n"
|
||||||
"(или отправьте '-' чтобы пропустить):"
|
"(или отправьте '-' чтобы пропустить):"
|
||||||
)
|
)
|
||||||
await state.set_state(RegistrationStates.waiting_for_phone)
|
await state.set_state(RegistrationStates.waiting_for_phone)
|
||||||
@@ -69,10 +146,24 @@ async def process_club_card(message: Message, state: FSMContext):
|
|||||||
@router.message(StateFilter(RegistrationStates.waiting_for_phone))
|
@router.message(StateFilter(RegistrationStates.waiting_for_phone))
|
||||||
async def process_phone(message: Message, state: FSMContext):
|
async def process_phone(message: Message, state: FSMContext):
|
||||||
"""Обработка номера телефона"""
|
"""Обработка номера телефона"""
|
||||||
phone = None if message.text.strip() == "-" else message.text.strip()
|
phone_input = message.text.strip()
|
||||||
|
|
||||||
|
# Проверяем, не отправил ли пользователь просто "-"
|
||||||
|
if phone_input == "-":
|
||||||
|
phone = None
|
||||||
|
else:
|
||||||
|
# Валидируем телефон: не должно быть пустых или некорректных значений
|
||||||
|
if not phone_input:
|
||||||
|
await message.answer(
|
||||||
|
"❌ Неверный номер телефона.\n\n"
|
||||||
|
"Пожалуйста, введите корректный номер или отправьте '-' чтобы пропустить."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
phone = phone_input
|
||||||
|
|
||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
club_card_number = data['club_card_number']
|
club_card_number = data['club_card_number']
|
||||||
|
nickname = data.get('nickname')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
@@ -82,16 +173,23 @@ async def process_phone(message: Message, state: FSMContext):
|
|||||||
club_card_number=club_card_number,
|
club_card_number=club_card_number,
|
||||||
phone=phone
|
phone=phone
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Обновляем никнейм пользователя
|
||||||
|
if nickname:
|
||||||
|
user.nickname = nickname
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
|
||||||
text = (
|
text = (
|
||||||
"✅ Регистрация завершена!\n\n"
|
"✅ Регистрация завершена!\n\n"
|
||||||
|
f"🎭 Никнейм: {user.nickname}\n"
|
||||||
f"🎫 Клубная карта: {user.club_card_number}\n"
|
f"🎫 Клубная карта: {user.club_card_number}\n"
|
||||||
f"🔑 Ваш код верификации: **{user.verification_code}**\n\n"
|
f"🔑 Ваш код верификации: <b>{user.verification_code}</b>\n\n"
|
||||||
"⚠️ Сохраните этот код! Он понадобится для подтверждения выигрыша.\n\n"
|
"⚠️ Сохраните этот код! Он понадобится для подтверждения выигрыша.\n\n"
|
||||||
"Теперь вы можете участвовать в розыгрышах!"
|
"Теперь вы можете участвовать в розыгрышах!"
|
||||||
)
|
)
|
||||||
|
|
||||||
await message.answer(text, parse_mode="Markdown")
|
await message.answer(text, parse_mode="HTML")
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -117,17 +215,17 @@ async def show_verification_code(message: Message):
|
|||||||
|
|
||||||
text = (
|
text = (
|
||||||
"🔑 Ваш код верификации:\n\n"
|
"🔑 Ваш код верификации:\n\n"
|
||||||
f"**{user.verification_code}**\n\n"
|
f"<code>{user.verification_code}</code>\n\n"
|
||||||
"Этот код используется для подтверждения выигрыша.\n"
|
"Этот код используется для подтверждения выигрыша.\n"
|
||||||
"Сообщите его администратору при получении приза."
|
"Сообщите его администратору при получении приза."
|
||||||
)
|
)
|
||||||
|
|
||||||
await message.answer(text, parse_mode="Markdown")
|
await message.answer(text, parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("my_accounts"))
|
@router.message(Command("my_accounts"))
|
||||||
async def show_user_accounts(message: Message):
|
async def show_user_accounts(message: Message):
|
||||||
"""Показать счета пользователя"""
|
"""Показать логины пользователя с информацией о розыгрышах"""
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||||
|
|
||||||
@@ -139,15 +237,69 @@ async def show_user_accounts(message: Message):
|
|||||||
|
|
||||||
if not accounts:
|
if not accounts:
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"У вас пока нет привязанных счетов.\n\n"
|
"У вас пока нет привязанных логинов.\n\n"
|
||||||
"Счета добавляются администратором."
|
"Логины добавляются администратором."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
text = f"💳 Ваши счета (Клубная карта: {user.club_card_number}):\n\n"
|
text = f"📱 <b>Ваши логины</b> (Клубная карта: {user.club_card_number})\n\n"
|
||||||
|
|
||||||
for i, account in enumerate(accounts, 1):
|
for i, account in enumerate(accounts, 1):
|
||||||
status = "✅" if account.is_active else "❌"
|
# Получаем participations для этого account с загруженными данными о lottery
|
||||||
text += f"{i}. {status} {account.account_number}\n"
|
participations = await session.execute(
|
||||||
|
select(Participation)
|
||||||
|
.where(Participation.account_id == account.id)
|
||||||
|
.options(selectinload(Participation.lottery))
|
||||||
|
)
|
||||||
|
participations = participations.scalars().all()
|
||||||
|
|
||||||
|
# Определяем статус логина
|
||||||
|
active_participations = [p for p in participations if not p.lottery.is_completed]
|
||||||
|
closed_participations = [p for p in participations if p.lottery.is_completed]
|
||||||
|
|
||||||
|
# Основная информация о логине
|
||||||
|
status_icon = "✅" if account.is_active and active_participations else "⏸️"
|
||||||
|
text += f"{i}. {status_icon} <b>{account.account_number}</b>\n"
|
||||||
|
|
||||||
|
if active_participations:
|
||||||
|
text += " 🎲 <b>Активные розыгрыши:</b>\n"
|
||||||
|
for p in active_participations[:5]: # Показываем не более 5
|
||||||
|
status = "🟢"
|
||||||
|
text += f" {status} {p.lottery.title}\n"
|
||||||
|
if len(active_participations) > 5:
|
||||||
|
text += f" ... и еще {len(active_participations) - 5}\n"
|
||||||
|
|
||||||
|
if closed_participations:
|
||||||
|
text += " 🏁 <b>Завершенные розыгрыши:</b>\n"
|
||||||
|
for p in closed_participations[:3]: # Показываем не более 3
|
||||||
|
# Проверяем, выиграл ли в этом розыгрыше
|
||||||
|
winner_result = await session.execute(
|
||||||
|
select(Winner)
|
||||||
|
.where(
|
||||||
|
(Winner.lottery_id == p.lottery_id) &
|
||||||
|
(Winner.account_number == account.account_number)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
winner = winner_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if winner:
|
||||||
|
text += f" 🏆 {p.lottery.title} - <b>ВЫИГРАЛ!</b> ({winner.place} место)\n"
|
||||||
|
else:
|
||||||
|
text += f" ✗ {p.lottery.title}\n"
|
||||||
|
|
||||||
|
if len(closed_participations) > 3:
|
||||||
|
text += f" ... и еще {len(closed_participations) - 3} закрытых\n"
|
||||||
|
|
||||||
|
if not participations:
|
||||||
|
text += " ℹ️ В розыгрышах не участвовал\n"
|
||||||
|
|
||||||
|
text += "\n"
|
||||||
|
|
||||||
await message.answer(text)
|
# Добавляем примечание о неактивных логинах
|
||||||
|
if any(not acc.is_active for acc in accounts):
|
||||||
|
text += "⏸️ - Логин не участвует в новых розыгрышах\n"
|
||||||
|
text += "✅ - Логин активен и может участвовать\n\n"
|
||||||
|
|
||||||
|
text += "💡 <b>Заметка:</b> Логины, участвовавшие в закрытых розыгрышах, не добавляются в новые розыгрыши."
|
||||||
|
|
||||||
|
await message.answer(text, parse_mode="HTML")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from aiogram import Router, F
|
|||||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
|
|
||||||
|
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||||
from src.core.config import ADMIN_IDS
|
from src.core.config import ADMIN_IDS
|
||||||
from src.core.permissions import is_admin
|
from src.core.permissions import is_admin
|
||||||
|
|
||||||
@@ -14,9 +15,9 @@ from src.core.permissions import is_admin
|
|||||||
test_router = Router()
|
test_router = Router()
|
||||||
|
|
||||||
|
|
||||||
@test_router.message(Command("test_start"))
|
@test_router.message(CaseInsensitiveCommand("test_start"))
|
||||||
async def cmd_test_start(message: Message):
|
async def cmd_test_start(message: Message):
|
||||||
"""Тестовая команда /test_start"""
|
"""Тестовая команда /test_start (регистронезависимо)"""
|
||||||
user_id = message.from_user.id
|
user_id = message.from_user.id
|
||||||
first_name = message.from_user.first_name
|
first_name = message.from_user.first_name
|
||||||
is_admin_user = is_admin(user_id)
|
is_admin_user = is_admin(user_id)
|
||||||
@@ -47,9 +48,9 @@ async def cmd_test_start(message: Message):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@test_router.message(Command("test_admin"))
|
@test_router.message(CaseInsensitiveCommand("test_admin"))
|
||||||
async def cmd_test_admin(message: Message):
|
async def cmd_test_admin(message: Message):
|
||||||
"""Тестовая команда /test_admin"""
|
"""Тестовая команда /test_admin (регистронезависимо)"""
|
||||||
if not is_admin(message.from_user.id):
|
if not is_admin(message.from_user.id):
|
||||||
await message.answer("❌ У вас нет прав для выполнения этой команды")
|
await message.answer("❌ У вас нет прав для выполнения этой команды")
|
||||||
return
|
return
|
||||||
|
|||||||
6
src/middlewares/__init__.py
Normal file
6
src/middlewares/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Middleware для бота
|
||||||
|
"""
|
||||||
|
from .activity import ActivityMiddleware
|
||||||
|
|
||||||
|
__all__ = ['ActivityMiddleware']
|
||||||
52
src/middlewares/activity.py
Normal file
52
src/middlewares/activity.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""
|
||||||
|
Middleware для отслеживания активности пользователей
|
||||||
|
"""
|
||||||
|
from typing import Callable, Dict, Any, Awaitable
|
||||||
|
from aiogram import BaseMiddleware
|
||||||
|
from aiogram.types import TelegramObject, Update, Message, CallbackQuery
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from src.core.database import async_session_maker
|
||||||
|
from src.core.activity_service import ActivityService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityMiddleware(BaseMiddleware):
|
||||||
|
"""Middleware для обновления last_activity при каждом взаимодействии"""
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||||
|
event: TelegramObject,
|
||||||
|
data: Dict[str, Any]
|
||||||
|
) -> Any:
|
||||||
|
# Получаем telegram_id из события
|
||||||
|
telegram_id = None
|
||||||
|
|
||||||
|
if isinstance(event, Message):
|
||||||
|
telegram_id = event.from_user.id if event.from_user else None
|
||||||
|
elif isinstance(event, CallbackQuery):
|
||||||
|
telegram_id = event.from_user.id if event.from_user else None
|
||||||
|
elif isinstance(event, Update):
|
||||||
|
if event.message and event.message.from_user:
|
||||||
|
telegram_id = event.message.from_user.id
|
||||||
|
elif event.callback_query and event.callback_query.from_user:
|
||||||
|
telegram_id = event.callback_query.from_user.id
|
||||||
|
|
||||||
|
# Обновляем активность если есть telegram_id
|
||||||
|
if telegram_id:
|
||||||
|
try:
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
# Обновляем активность
|
||||||
|
await ActivityService.update_user_activity(session, telegram_id)
|
||||||
|
|
||||||
|
# Проверяем, не был ли пользователь заблокирован за неактивность
|
||||||
|
# Если был - реактивируем
|
||||||
|
await ActivityService.reactivate_user(session, telegram_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка в ActivityMiddleware для пользователя {telegram_id}: {e}")
|
||||||
|
|
||||||
|
# Вызываем следующий обработчик
|
||||||
|
return await handler(event, data)
|
||||||
80
src/utils/keyboards.py
Normal file
80
src/utils/keyboards.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Вспомогательные функции для создания клавиатур"""
|
||||||
|
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton
|
||||||
|
|
||||||
|
|
||||||
|
def get_main_reply_keyboard(is_admin: bool = False, is_registered: bool = False) -> ReplyKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Получить главную обычную клавиатуру с командами
|
||||||
|
|
||||||
|
Args:
|
||||||
|
is_admin: Является ли пользователь администратором
|
||||||
|
is_registered: Зарегистрирован ли пользователь
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ReplyKeyboardMarkup с кнопками команд
|
||||||
|
"""
|
||||||
|
keyboard = []
|
||||||
|
|
||||||
|
# Первая строка - основные команды
|
||||||
|
row1 = [
|
||||||
|
KeyboardButton(text="💬 Чат"),
|
||||||
|
KeyboardButton(text="❓ Справка")
|
||||||
|
]
|
||||||
|
keyboard.append(row1)
|
||||||
|
|
||||||
|
# Вторая строка - дополнительные команды
|
||||||
|
row2 = []
|
||||||
|
if not is_admin and not is_registered:
|
||||||
|
row2.append(KeyboardButton(text="📝 Регистрация"))
|
||||||
|
|
||||||
|
if is_registered or is_admin:
|
||||||
|
row2.append(KeyboardButton(text="🔑 Мой код"))
|
||||||
|
row2.append(KeyboardButton(text="📱 Мои логины"))
|
||||||
|
|
||||||
|
if row2:
|
||||||
|
keyboard.append(row2)
|
||||||
|
|
||||||
|
# Третья строка - главная
|
||||||
|
row3 = [KeyboardButton(text="🏠 Главная")]
|
||||||
|
|
||||||
|
# Админские команды
|
||||||
|
if is_admin:
|
||||||
|
row3.append(KeyboardButton(text="⚙️ Админ панель"))
|
||||||
|
|
||||||
|
keyboard.append(row3)
|
||||||
|
|
||||||
|
return ReplyKeyboardMarkup(
|
||||||
|
keyboard=keyboard,
|
||||||
|
resize_keyboard=True,
|
||||||
|
input_field_placeholder="Выберите действие..."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_chat_reply_keyboard() -> ReplyKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Получить клавиатуру для режима чата
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ReplyKeyboardMarkup с кнопками управления чатом
|
||||||
|
"""
|
||||||
|
keyboard = [
|
||||||
|
[KeyboardButton(text="🚪 Выйти из чата")],
|
||||||
|
[KeyboardButton(text="🏠 Главная")]
|
||||||
|
]
|
||||||
|
|
||||||
|
return ReplyKeyboardMarkup(
|
||||||
|
keyboard=keyboard,
|
||||||
|
resize_keyboard=True,
|
||||||
|
input_field_placeholder="Напишите сообщение или выберите действие..."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_keyboard() -> ReplyKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Убрать обычную клавиатуру
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ReplyKeyboardMarkup с параметром remove_keyboard=True
|
||||||
|
"""
|
||||||
|
from aiogram.types import ReplyKeyboardRemove
|
||||||
|
return ReplyKeyboardRemove()
|
||||||
101
test_chat_fix.md
Normal file
101
test_chat_fix.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Исправление функции чата
|
||||||
|
|
||||||
|
## 🔴 Проблема
|
||||||
|
При переходе в чат, сообщения не отправлялись другим участникам. Пользователи не получали сообщения друг от друга.
|
||||||
|
|
||||||
|
## 🔍 Корневые причины (найдено ДВЕ)
|
||||||
|
|
||||||
|
### Причина 1: Неправильная фильтрация активных пользователей
|
||||||
|
В функции `get_all_active_users()` (строка 189-192) рассылка осуществлялась только:
|
||||||
|
- Зарегистрированным пользователям (`u.is_registered == True`)
|
||||||
|
- ИЛИ админам
|
||||||
|
|
||||||
|
Это означало, что обычные пользователи, не прошедшие полную регистрацию, не получали сообщения в чате.
|
||||||
|
|
||||||
|
**Статус в БД:** Было 7 пользователей, из них только 2 зарегистрированы, остальные 5 не получали сообщения.
|
||||||
|
|
||||||
|
### Причина 2: Дублирующиеся обработчики текстовых сообщений
|
||||||
|
В файле `src/handlers/chat_handlers.py` было ДВА обработчика для текстовых сообщений в состоянии `ChatStates.in_chat`:
|
||||||
|
|
||||||
|
1. **`check_exit_keywords()` (строка 140)**:
|
||||||
|
- Декоратор: `@router.message(StateFilter(ChatStates.in_chat), F.text)`
|
||||||
|
- Функция: проверяла ключевые слова для выхода (`/start`, `start`, `старт`, `/exit`)
|
||||||
|
- **ПРОБЛЕМА**: если сообщение не было ключевым словом, функция просто заканчивалась без `return`, но это НЕ означало, что выполнение продолжится в следующем обработчике. Aiogram использует первый подходящий обработчик, и второй никогда не вызывался.
|
||||||
|
|
||||||
|
2. **`handle_text_message()` (строка 663)** - дублирующий обработчик:
|
||||||
|
- Декоратор: `@router.message(F.text, StateFilter(ChatStates.in_chat))`
|
||||||
|
- Функция: содержала вся логика для рассылки сообщений
|
||||||
|
- **ПРОБЛЕМА**: эта функция НИКОГДА не вызывалась, потому что первый обработчик `check_exit_keywords()` перехватывал все текстовые сообщения.
|
||||||
|
|
||||||
|
## ✅ Сделанные исправления
|
||||||
|
|
||||||
|
### Исправление 1: Изменена логика получения активных пользователей
|
||||||
|
```python
|
||||||
|
# ДО (неправильно):
|
||||||
|
async def get_all_active_users(session: AsyncSession) -> List:
|
||||||
|
"""Получить всех пользователей для рассылки (зарегистрированные + админы)"""
|
||||||
|
users = await UserService.get_all_users(session)
|
||||||
|
return [u for u in users if u.is_registered or u.telegram_id in ADMIN_IDS]
|
||||||
|
|
||||||
|
# ПОСЛЕ (правильно):
|
||||||
|
async def get_all_active_users(session: AsyncSession) -> List:
|
||||||
|
"""Получить всех пользователей для рассылки (всем, кто когда-либо общался с ботом)"""
|
||||||
|
users = await UserService.get_all_users(session)
|
||||||
|
return users
|
||||||
|
```
|
||||||
|
|
||||||
|
### Исправление 2: Объединены дублирующиеся обработчики
|
||||||
|
- **Объединена вся логика обработки сообщений в `check_exit_keywords()`** (теперь переименована концептуально, но осталась в коде)
|
||||||
|
- **Удален дублирующий обработчик `handle_text_message()`**
|
||||||
|
- Новая логика:
|
||||||
|
1. Проверяются ключевые слова для выхода (`/start`, `start`, `старт`, `/exit`)
|
||||||
|
2. **Если это не ключевое слово** → продолжается обработка как обычного сообщения чата
|
||||||
|
3. Выполняется полная логика рассылки/пересылки
|
||||||
|
|
||||||
|
### Исправление 3: Добавлено логирование для отладки
|
||||||
|
Добавлены логи в `broadcast_message_with_scheduler()`:
|
||||||
|
```python
|
||||||
|
logger.info(f"[CHAT] broadcast_message_with_scheduler: всего пользователей для рассылки: {len(users)}")
|
||||||
|
logger.info(f"[CHAT] После исключения отправителя: {len(users)} пользователей")
|
||||||
|
logger.info(f"[CHAT] broadcast_message_with_scheduler завершена: успешно={success_count}, ошибок={fail_count}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Измененные файлы
|
||||||
|
|
||||||
|
- **src/handlers/chat_handlers.py**:
|
||||||
|
- Строка 189-192: Функция `get_all_active_users()` теперь возвращает **всех** пользователей
|
||||||
|
- Строка 140-358: Объединена вся логика обработки текстовых сообщений в функцию `check_exit_keywords()`
|
||||||
|
- Строка 663-857: **Удален** дублирующий обработчик `handle_text_message()`
|
||||||
|
|
||||||
|
## 🧪 Тестирование
|
||||||
|
|
||||||
|
### Инструкции для тестирования:
|
||||||
|
|
||||||
|
1. **Убедитесь, что есть минимум 2 пользователя в системе** (заказывали с 7 пользователями)
|
||||||
|
2. **Первый пользователь**: отправляет `/chat` или нажимает "Войти в чат"
|
||||||
|
3. **Второй пользователь**: отправляет `/chat` или нажимает "Войти в чат"
|
||||||
|
4. **Первый пользователь**: отправляет текстовое сообщение в чат
|
||||||
|
5. **Второй пользователь**: должен **получить сообщение** с заголовком типа:
|
||||||
|
- Для админов: `📨 Сообщение от [nickname] (карта: XXXX):`
|
||||||
|
- Для обычных пользователей: `📨 [nickname]:`
|
||||||
|
|
||||||
|
### Проверка логов:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f bot | grep "\[CHAT\]"
|
||||||
|
```
|
||||||
|
|
||||||
|
Должны быть строки:
|
||||||
|
- `[CHAT] check_exit_keywords вызван для обработки: user=...`
|
||||||
|
- `[CHAT] broadcast_message_with_scheduler: всего пользователей для рассылки: N`
|
||||||
|
- `[CHAT] После исключения отправителя: N пользователей`
|
||||||
|
- `[CHAT] broadcast_message_with_scheduler завершена: успешно=N, ошибок=M`
|
||||||
|
|
||||||
|
## 🎯 Ожидаемый результат
|
||||||
|
|
||||||
|
После применения этого исправления:
|
||||||
|
✅ Все пользователи будут получать сообщения в чате
|
||||||
|
✅ Сообщения будут рассылаться **независимо от статуса регистрации**
|
||||||
|
✅ Логирование позволит отследить проблемы при возникновении
|
||||||
|
✅ Система корректно проверяет ключевые слова для выхода из чата
|
||||||
|
✅ Сообщения рассылаются **всем** пользователям, включая незарегистрированных
|
||||||
Reference in New Issue
Block a user