Merge branch 'v2_functions'
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
18
.env.prod
18
.env.prod
@@ -2,20 +2,20 @@
|
||||
# Скопируйте этот файл в .env.prod и заполните реальными значениями
|
||||
|
||||
# Telegram Bot Token
|
||||
BOT_TOKEN=8300330445:AAFyxAqtmWsgtSPa_nb-lH3Q4ovmn9Ei6rA
|
||||
BOT_TOKEN=8125171867:AAHA0l2hGGodOUBh0rFlkE4CxK0X6JzZv64
|
||||
|
||||
# PostgreSQL настройки для внешней БД
|
||||
# Замените на данные вашего внешнего PostgreSQL сервера
|
||||
POSTGRES_HOST=192.168.0.102
|
||||
# PostgreSQL настройки для Docker контейнера
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=lottery_bot
|
||||
POSTGRES_USER=trevor
|
||||
POSTGRES_USER=lottery_user
|
||||
POSTGRES_PASSWORD=Cl0ud_1985!
|
||||
|
||||
# Database URL для бота
|
||||
# Формат: postgresql+asyncpg://user:password@host:port/database
|
||||
# Для внешнего сервера укажите его IP или домен вместо localhost
|
||||
DATABASE_URL=postgresql+asyncpg://trevor:Cl0ud_1985!@192.168.0.102:5432/lottery_bot
|
||||
# Database URL для бота (использует postgres как hostname внутри Docker сети)
|
||||
DATABASE_URL=postgresql+asyncpg://lottery_user:Cl0ud_1985!@postgres:5432/lottery_bot
|
||||
|
||||
# Redis URL
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# ID администраторов (через запятую)
|
||||
ADMIN_IDS=556399210,6639865742
|
||||
|
||||
@@ -2,6 +2,41 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: lottery_postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-lottery_bot}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-lottery_user}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-lottery_password}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- lottery_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lottery_user}"]
|
||||
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
|
||||
bot:
|
||||
build:
|
||||
@@ -12,15 +47,18 @@ services:
|
||||
env_file:
|
||||
- .env.prod
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- BOT_TOKEN=${BOT_TOKEN}
|
||||
- ADMIN_IDS=${ADMIN_IDS}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379/0}
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
- bot_data:/app/data
|
||||
networks:
|
||||
- lottery_network
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import sys; sys.exit(0)"]
|
||||
interval: 30s
|
||||
@@ -31,6 +69,10 @@ services:
|
||||
volumes:
|
||||
bot_data:
|
||||
driver: local
|
||||
postgres_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
lottery_network:
|
||||
|
||||
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. Статистика активности по дням/неделям/месяцам
|
||||
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
|
||||
- Кэширование заблокированных пользователей ускоряет рассылку
|
||||
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+ пользователей
|
||||
- ✅ Интуитивный интерфейс для администраторов
|
||||
- ✅ Интеграцию с системой разрешений чата
|
||||
|
||||
Система готова к использованию и может быть расширена дополнительными функциями по мере необходимости.
|
||||
140
main.py
140
main.py
@@ -10,11 +10,16 @@ from aiogram import Bot, Dispatcher, Router, F
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram.filters import Command
|
||||
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.database import async_session_maker
|
||||
from src.core.scheduler import bot_scheduler
|
||||
from src.container import container
|
||||
from src.interfaces.base import IBotController
|
||||
from src.middlewares.activity import ActivityMiddleware
|
||||
from src.handlers.admin_panel import admin_router
|
||||
from src.handlers.registration_handlers import router as registration_router
|
||||
from src.handlers.admin_account_handlers import router as admin_account_router
|
||||
@@ -24,6 +29,7 @@ from src.handlers.admin_chat_handlers import router as admin_chat_router
|
||||
from src.handlers.account_handlers import account_router
|
||||
from src.handlers.message_management import message_admin_router
|
||||
from src.handlers.p2p_chat import router as p2p_chat_router
|
||||
from src.handlers.help_handlers import router as help_router
|
||||
|
||||
# Настройка логирования
|
||||
logging.basicConfig(
|
||||
@@ -60,16 +66,131 @@ async def get_controller():
|
||||
|
||||
# === COMMAND HANDLERS ===
|
||||
|
||||
@router.message(Command("start"))
|
||||
@router.message(CaseInsensitiveCommand("start"))
|
||||
async def cmd_start(message: Message):
|
||||
"""Обработчик команды /start"""
|
||||
"""Обработчик команды /start (регистронезависимо)"""
|
||||
async with get_controller() as controller:
|
||||
await controller.handle_start(message)
|
||||
|
||||
|
||||
@router.message(Command("admin"))
|
||||
# === TEXT BUTTON HANDLERS ===
|
||||
|
||||
@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
|
||||
|
||||
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 aiogram.types import CallbackQuery
|
||||
|
||||
fake_callback = CallbackQuery(
|
||||
id="fake",
|
||||
from_user=message.from_user,
|
||||
chat_instance="0",
|
||||
data="start_registration",
|
||||
message=message
|
||||
)
|
||||
|
||||
from src.handlers.registration_handlers import start_registration
|
||||
await start_registration(fake_callback, 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 == "💳 Мои счета")
|
||||
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"""
|
||||
"""Обработчик команды /admin (регистронезависимо) - перенаправляет в admin_panel"""
|
||||
from src.core.config import ADMIN_IDS
|
||||
if message.from_user.id not in ADMIN_IDS:
|
||||
await message.answer("❌ Недостаточно прав для доступа к админ панели")
|
||||
@@ -116,6 +237,10 @@ async def main():
|
||||
"""Главная функция запуска бота"""
|
||||
logger.info("Запуск бота...")
|
||||
|
||||
# Подключаем middleware для отслеживания активности
|
||||
dp.message.middleware(ActivityMiddleware())
|
||||
dp.callback_query.middleware(ActivityMiddleware())
|
||||
|
||||
# Подключаем роутеры в правильном порядке
|
||||
# 1. Основной роутер main.py с базовыми командами (/start, /help, /admin)
|
||||
dp.include_router(router)
|
||||
@@ -128,6 +253,7 @@ async def main():
|
||||
dp.include_router(admin_chat_router) # Админские команды чата
|
||||
dp.include_router(redraw_router) # Повторные розыгрыши
|
||||
dp.include_router(p2p_chat_router) # P2P чат между пользователями
|
||||
dp.include_router(help_router) # Справка и помощь
|
||||
|
||||
# 3. Chat router для broadcast (обрабатывает обычные сообщения)
|
||||
dp.include_router(chat_router) # Пользовательский чат (broadcast всем) - РАНЬШЕ account_router
|
||||
@@ -135,6 +261,10 @@ async def main():
|
||||
# 4. Account router для обнаружения счетов (обрабатывает сообщения со счетами от админов)
|
||||
dp.include_router(account_router) # Обнаружение счетов для админов - ПОСЛЕ chat_router
|
||||
|
||||
# Запускаем планировщик задач
|
||||
bot_scheduler.start()
|
||||
logger.info("Планировщик задач запущен")
|
||||
|
||||
# Запускаем polling
|
||||
try:
|
||||
logger.info("Бот запущен")
|
||||
@@ -142,6 +272,8 @@ async def main():
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при запуске бота: {e}")
|
||||
finally:
|
||||
# Останавливаем планировщик
|
||||
bot_scheduler.shutdown()
|
||||
await bot.session.close()
|
||||
|
||||
|
||||
|
||||
@@ -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')
|
||||
@@ -6,3 +6,7 @@ alembic==1.14.0
|
||||
python-dotenv==1.0.1
|
||||
asyncpg==0.30.0
|
||||
aiosqlite==0.20.0
|
||||
redis==5.2.1
|
||||
aioredis==2.0.1
|
||||
apscheduler==3.10.4
|
||||
openpyxl==3.1.2
|
||||
@@ -11,8 +11,9 @@ class KeyboardBuilderImpl(IKeyboardBuilder):
|
||||
def get_main_keyboard(self, is_admin: bool = False, is_registered: bool = False):
|
||||
"""Получить главную клавиатуру"""
|
||||
buttons = [
|
||||
[InlineKeyboardButton(text="🎲 Активные розыгрыши", callback_data="active_lotteries")],
|
||||
[InlineKeyboardButton(text="💬 Войти в чат", callback_data="enter_chat")]
|
||||
[InlineKeyboardButton(text="🎰 Активные розыгрыши", callback_data="active_lotteries")],
|
||||
[InlineKeyboardButton(text="💬 Войти в чат", callback_data="enter_chat")],
|
||||
[InlineKeyboardButton(text="❓ Справка", callback_data="help_main")]
|
||||
]
|
||||
|
||||
# Показываем кнопку регистрации только незарегистрированным пользователям (не админам)
|
||||
@@ -22,7 +23,7 @@ class KeyboardBuilderImpl(IKeyboardBuilder):
|
||||
if is_admin:
|
||||
buttons.extend([
|
||||
[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)
|
||||
@@ -30,13 +31,14 @@ class KeyboardBuilderImpl(IKeyboardBuilder):
|
||||
def get_admin_keyboard(self):
|
||||
"""Получить админскую клавиатуру"""
|
||||
buttons = [
|
||||
[InlineKeyboardButton(text="🎲 Управление розыгрышами", callback_data="admin_lotteries")],
|
||||
[InlineKeyboardButton(text="👥 Управление участниками", callback_data="admin_participants")],
|
||||
[InlineKeyboardButton(text="👑 Управление победителями", callback_data="admin_winners")],
|
||||
[InlineKeyboardButton(text="💬 Сообщения пользователей", callback_data="admin_messages")],
|
||||
[InlineKeyboardButton(text="📊 Статистика", callback_data="admin_stats")],
|
||||
[InlineKeyboardButton(text="🎰 Розыгрыши", callback_data="admin_lotteries"),
|
||||
InlineKeyboardButton(text="🏆 Победители", callback_data="admin_winners")],
|
||||
[InlineKeyboardButton(text="📋 Участники", callback_data="admin_participants"),
|
||||
InlineKeyboardButton(text="👥 Пользователи", callback_data="admin_users")],
|
||||
[InlineKeyboardButton(text="📢 Рассылки", callback_data="admin_broadcast"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_stats")],
|
||||
[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)
|
||||
|
||||
@@ -52,12 +54,12 @@ class MessageFormatterImpl(IMessageFormatter):
|
||||
|
||||
if is_admin:
|
||||
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"delete_{lottery_id}")]
|
||||
])
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="🔙 Назад", callback_data="active_lotteries")])
|
||||
buttons.append([InlineKeyboardButton(text="◀️ Назад", callback_data="active_lotteries")])
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def get_conduct_lottery_keyboard(self, lotteries: List[Lottery]):
|
||||
@@ -70,7 +72,7 @@ class MessageFormatterImpl(IMessageFormatter):
|
||||
text = text[:47] + "..."
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ class BotController(IBotController):
|
||||
|
||||
async def handle_start(self, message: Message):
|
||||
"""Обработать команду /start"""
|
||||
from src.utils.keyboards import get_main_reply_keyboard
|
||||
|
||||
user = await self.user_service.get_or_create_user(
|
||||
telegram_id=message.from_user.id,
|
||||
username=message.from_user.username,
|
||||
@@ -49,14 +51,27 @@ class BotController(IBotController):
|
||||
else:
|
||||
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_registered=user.is_registered
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
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):
|
||||
|
||||
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:
|
||||
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)
|
||||
if is_banned:
|
||||
return False, "Вы заблокированы и не можете отправлять сообщения"
|
||||
|
||||
@@ -12,6 +12,9 @@ if not BOT_TOKEN:
|
||||
# База данных
|
||||
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_str = os.getenv("ADMIN_IDS", "")
|
||||
|
||||
@@ -19,7 +19,9 @@ class User(Base):
|
||||
club_card_number = Column(String(50), unique=True, nullable=True, index=True) # Номер клубной карты
|
||||
is_registered = 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))
|
||||
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)
|
||||
@@ -243,3 +245,71 @@ class P2PMessage(Base):
|
||||
|
||||
def __repr__(self):
|
||||
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})>"
|
||||
|
||||
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()
|
||||
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 sqlalchemy import select, and_
|
||||
|
||||
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||
from src.core.database import async_session_maker
|
||||
from src.core.registration_services import AccountService, WinnerNotificationService, RegistrationService
|
||||
from src.core.services import UserService, LotteryService, ParticipationService
|
||||
@@ -22,19 +23,19 @@ class AddAccountStates(StatesGroup):
|
||||
choosing_lottery = State()
|
||||
|
||||
|
||||
@router.message(Command("cancel"))
|
||||
@router.message(CaseInsensitiveCommand("cancel"))
|
||||
@admin_only
|
||||
async def cancel_command(message: Message, state: FSMContext):
|
||||
"""Отменить текущую операцию и сбросить состояние"""
|
||||
"""Отменить текущую операцию и сбросить состояние (регистронезависимо)"""
|
||||
await state.clear()
|
||||
await message.answer("✅ Состояние сброшено. Все операции отменены.")
|
||||
|
||||
|
||||
@router.message(Command("add_account"))
|
||||
@router.message(CaseInsensitiveCommand("add_account"))
|
||||
@admin_only
|
||||
async def add_account_command(message: Message, state: FSMContext):
|
||||
"""
|
||||
Добавить счет пользователю по клубной карте
|
||||
Добавить счет пользователю по клубной карте (регистронезависимо)
|
||||
Формат: /add_account <club_card> <account_number>
|
||||
Или: /add_account (затем вводить данные построчно)
|
||||
"""
|
||||
@@ -434,11 +435,11 @@ async def skip_lottery_add(callback: CallbackQuery, state: FSMContext):
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.message(Command("remove_account"))
|
||||
@router.message(CaseInsensitiveCommand("remove_account"))
|
||||
@admin_only
|
||||
async def remove_account_command(message: Message):
|
||||
"""
|
||||
Деактивировать счет(а)
|
||||
Деактивировать счет(а) (регистронезависимо)
|
||||
Формат: /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)}")
|
||||
|
||||
|
||||
@router.message(Command("verify_winner"))
|
||||
@router.message(CaseInsensitiveCommand("verify_winner"))
|
||||
@admin_only
|
||||
async def verify_winner_command(message: Message):
|
||||
"""
|
||||
Подтвердить выигрыш по коду верификации
|
||||
Подтвердить выигрыш по коду верификации (регистронезависимо)
|
||||
Формат: /verify_winner <verification_code> <lottery_id>
|
||||
Пример: /verify_winner AB12CD34 1
|
||||
"""
|
||||
@@ -595,11 +596,11 @@ async def verify_winner_command(message: Message):
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
|
||||
@router.message(Command("winner_status"))
|
||||
@router.message(CaseInsensitiveCommand("winner_status"))
|
||||
@admin_only
|
||||
async def winner_status_command(message: Message):
|
||||
"""
|
||||
Показать статус всех победителей розыгрыша
|
||||
Показать статус всех победителей розыгрыша (регистронезависимо)
|
||||
Формат: /winner_status <lottery_id>
|
||||
"""
|
||||
|
||||
@@ -668,11 +669,11 @@ async def winner_status_command(message: Message):
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
|
||||
@router.message(Command("user_info"))
|
||||
@router.message(CaseInsensitiveCommand("user_info"))
|
||||
@admin_only
|
||||
async def user_info_command(message: Message):
|
||||
"""
|
||||
Показать информацию о пользователе
|
||||
Показать информацию о пользователе (регистронезависимо)
|
||||
Формат: /user_info <club_card>
|
||||
"""
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKe
|
||||
from aiogram.filters import Command
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||
from src.core.chat_services import (
|
||||
ChatSettingsService,
|
||||
BanService,
|
||||
@@ -29,10 +30,10 @@ def get_chat_mode_keyboard() -> InlineKeyboardMarkup:
|
||||
])
|
||||
|
||||
|
||||
@router.message(Command("chat_mode"))
|
||||
@router.message(CaseInsensitiveCommand("chat_mode"))
|
||||
@admin_only
|
||||
async def cmd_chat_mode(message: Message):
|
||||
"""Команда управления режимом чата"""
|
||||
"""Команда управления режимом чата (регистронезависимо)"""
|
||||
|
||||
async with async_session_maker() as session:
|
||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||
@@ -68,10 +69,10 @@ async def process_chat_mode(callback: CallbackQuery):
|
||||
await callback.answer("✅ Режим изменен")
|
||||
|
||||
|
||||
@router.message(Command("set_forward"))
|
||||
@router.message(CaseInsensitiveCommand("set_forward"))
|
||||
@admin_only
|
||||
async def cmd_set_forward(message: Message):
|
||||
"""Установить ID канала для пересылки"""
|
||||
"""Установить ID канала для пересылки (регистронезависимо)"""
|
||||
|
||||
args = message.text.split(maxsplit=1)
|
||||
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
|
||||
async def cmd_global_ban(message: Message):
|
||||
"""Включить/выключить глобальный бан чата"""
|
||||
"""Включить/выключить глобальный бан чата (регистронезависимо)"""
|
||||
|
||||
async with async_session_maker() as 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
|
||||
async def cmd_ban(message: 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
|
||||
async def cmd_unban(message: Message):
|
||||
"""Разбанить пользователя"""
|
||||
"""Разбанить пользователя (регистронезависимо)"""
|
||||
|
||||
# Проверяем является ли это ответом на сообщение
|
||||
if message.reply_to_message:
|
||||
@@ -232,10 +233,10 @@ async def cmd_unban(message: Message):
|
||||
await message.answer("❌ Пользователь не был забанен")
|
||||
|
||||
|
||||
@router.message(Command("banlist"))
|
||||
@router.message(CaseInsensitiveCommand("banlist"))
|
||||
@admin_only
|
||||
async def cmd_banlist(message: Message):
|
||||
"""Показать список забаненных пользователей"""
|
||||
"""Показать список заблокированных пользователей (регистронезависимо)"""
|
||||
|
||||
async with async_session_maker() as session:
|
||||
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")
|
||||
|
||||
|
||||
@router.message(Command("delete_msg"))
|
||||
@router.message(CaseInsensitiveCommand("delete_msg"))
|
||||
@admin_only
|
||||
async def cmd_delete_message(message: Message):
|
||||
"""Удалить сообщение из чата (пометить как удаленное)"""
|
||||
"""Удалить сообщение из чата (пометить как удаленное) (регистронезависимо)"""
|
||||
|
||||
if not message.reply_to_message:
|
||||
await message.answer(
|
||||
@@ -329,10 +330,10 @@ async def cmd_delete_message(message: Message):
|
||||
await message.answer("❌ Не удалось удалить сообщение")
|
||||
|
||||
|
||||
@router.message(Command("chat_stats"))
|
||||
@router.message(CaseInsensitiveCommand("chat_stats"))
|
||||
@admin_only
|
||||
async def cmd_chat_stats(message: Message):
|
||||
"""Статистика чата"""
|
||||
"""Статистика чата (регистронезависимо)"""
|
||||
|
||||
async with async_session_maker() as session:
|
||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,8 @@ from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKe
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.filters import StateFilter, Command
|
||||
|
||||
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import asyncio
|
||||
from typing import List, Dict, Optional, Set, Any
|
||||
@@ -43,9 +45,9 @@ def _contains_account_numbers(text: str) -> bool:
|
||||
router = Router(name='chat_router')
|
||||
|
||||
|
||||
@router.message(Command("chat"))
|
||||
@router.message(CaseInsensitiveCommand("chat"))
|
||||
async def enter_chat_command(message: Message, state: FSMContext):
|
||||
"""Войти в режим чата через команду /chat"""
|
||||
"""Войти в режим чата через команду /chat (регистронезависимо)"""
|
||||
await enter_chat(message, state)
|
||||
|
||||
|
||||
@@ -58,6 +60,8 @@ async def enter_chat_callback(callback: CallbackQuery, 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)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -65,19 +69,28 @@ async def enter_chat(message: Message, state: FSMContext):
|
||||
[InlineKeyboardButton(text="🏠 В главное меню", callback_data="back_to_main")]
|
||||
])
|
||||
|
||||
# Обычная клавиатура для чата
|
||||
reply_keyboard = get_chat_reply_keyboard()
|
||||
|
||||
await message.answer(
|
||||
"💬 <b>Вы вошли в режим чата</b>\n\n"
|
||||
"Теперь все ваши сообщения будут рассылаться участникам.\n"
|
||||
"Вы можете отправлять текст, фото, видео, документы и стикеры.\n\n"
|
||||
"Для выхода нажмите кнопку ниже или отправьте /exit",
|
||||
reply_markup=keyboard,
|
||||
reply_markup=reply_keyboard, # Обычная клавиатура
|
||||
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):
|
||||
"""Выйти из режима чата через команду /exit"""
|
||||
"""Выйти из режима чата через команду /exit (регистронезависимо)"""
|
||||
await exit_chat(message, state)
|
||||
|
||||
|
||||
@@ -90,19 +103,71 @@ async def exit_chat_callback(callback: CallbackQuery, 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()
|
||||
|
||||
# Получаем информацию о пользователе
|
||||
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=[
|
||||
[InlineKeyboardButton(text="💬 Войти в чат", callback_data="enter_chat")],
|
||||
[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(
|
||||
"✅ <b>Вы вышли из режима чата</b>\n\n"
|
||||
"Ваши сообщения больше не будут рассылаться.",
|
||||
reply_markup=reply_keyboard, # Обычная клавиатура
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
# Inline клавиатура отдельным сообщением
|
||||
await message.answer(
|
||||
"Выберите действие:",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
|
||||
@router.message(StateFilter(ChatStates.in_chat), F.text)
|
||||
async def check_exit_keywords(message: Message, state: FSMContext):
|
||||
"""Проверка на ключевые слова для выхода из чата"""
|
||||
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
|
||||
|
||||
# Если не ключевое слово, пропускаем дальше для обработки как обычное сообщение чата
|
||||
# Остальная логика обработки сообщений чата будет ниже
|
||||
|
||||
|
||||
# Настройки для планировщика рассылки
|
||||
|
||||
225
src/handlers/help_handlers.py
Normal file
225
src/handlers/help_handlers.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""Обработчики справки и помощи пользователям"""
|
||||
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_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> - список доступных команд"
|
||||
)
|
||||
|
||||
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_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.filters import Command
|
||||
|
||||
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||
from ..core.config import ADMIN_IDS
|
||||
from ..core.database import async_session_maker
|
||||
from ..core.chat_services import ChatMessageService
|
||||
@@ -21,10 +22,10 @@ def is_admin(user_id: int) -> bool:
|
||||
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):
|
||||
"""
|
||||
Удаление сообщения по команде /delete
|
||||
Удаление сообщения по команде /delete (регистронезависимо)
|
||||
Работает только если команда является ответом на сообщение бота
|
||||
"""
|
||||
if not is_admin(message.from_user.id):
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
from aiogram import Router, F
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -28,10 +30,10 @@ def is_admin(user_id: int) -> bool:
|
||||
return user_id in ADMIN_IDS
|
||||
|
||||
|
||||
@router.message(Command("chat"))
|
||||
@router.message(CaseInsensitiveCommand("chat"))
|
||||
async def show_chat_menu(message: Message, state: FSMContext):
|
||||
"""
|
||||
Главное меню чата
|
||||
Главное меню чата (регистронезависимо)
|
||||
/chat - показать меню с опциями общения
|
||||
"""
|
||||
# Очищаем состояние при входе в меню (выход из диалога)
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy import select, and_
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import random
|
||||
|
||||
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||
from src.core.database import async_session_maker
|
||||
from src.core.registration_services import AccountService, WinnerNotificationService
|
||||
from src.core.services import LotteryService
|
||||
@@ -17,11 +18,11 @@ from src.core.permissions import admin_only
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.message(Command("check_unclaimed"))
|
||||
@router.message(CaseInsensitiveCommand("check_unclaimed"))
|
||||
@admin_only
|
||||
async def check_unclaimed_winners(message: Message):
|
||||
"""
|
||||
Проверить неподтвержденные выигрыши (более 24 часов)
|
||||
Проверить неподтвержденные выигрыши (более 24 часов) (регистронезависимо)
|
||||
Формат: /check_unclaimed <lottery_id>
|
||||
"""
|
||||
|
||||
@@ -118,11 +119,11 @@ async def check_unclaimed_winners(message: Message):
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
|
||||
@router.message(Command("redraw"))
|
||||
@router.message(CaseInsensitiveCommand("redraw"))
|
||||
@admin_only
|
||||
async def redraw_lottery(message: Message):
|
||||
"""
|
||||
Переиграть розыгрыш для неподтвержденных выигрышей
|
||||
Переиграть розыгрыш для неподтвержденных выигрышей (регистронезависимо)
|
||||
Формат: /redraw <lottery_id>
|
||||
"""
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.filters import Command, StateFilter
|
||||
|
||||
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
import logging
|
||||
|
||||
@@ -7,6 +7,7 @@ from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.filters import Command
|
||||
|
||||
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||||
from src.core.config import ADMIN_IDS
|
||||
from src.core.permissions import is_admin
|
||||
|
||||
@@ -14,9 +15,9 @@ from src.core.permissions import is_admin
|
||||
test_router = Router()
|
||||
|
||||
|
||||
@test_router.message(Command("test_start"))
|
||||
@test_router.message(CaseInsensitiveCommand("test_start"))
|
||||
async def cmd_test_start(message: Message):
|
||||
"""Тестовая команда /test_start"""
|
||||
"""Тестовая команда /test_start (регистронезависимо)"""
|
||||
user_id = message.from_user.id
|
||||
first_name = message.from_user.first_name
|
||||
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):
|
||||
"""Тестовая команда /test_admin"""
|
||||
"""Тестовая команда /test_admin (регистронезависимо)"""
|
||||
if not is_admin(message.from_user.id):
|
||||
await message.answer("❌ У вас нет прав для выполнения этой команды")
|
||||
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()
|
||||
Reference in New Issue
Block a user