feat: добавлена система чата с модерацией
Some checks reported errors
continuous-integration/drone/push Build encountered an error
Some checks reported errors
continuous-integration/drone/push Build encountered an error
Реализована полнофункциональная система чата с двумя режимами работы: ## Режимы работы: - Broadcast: рассылка сообщений всем пользователям - Forward: пересылка сообщений в указанную группу/канал ## Функционал: - Поддержка всех типов сообщений: text, photo, video, document, animation, sticker, voice - Система банов: личные баны пользователей и глобальный бан чата - Модерация: удаление сообщений с отслеживанием в БД - История сообщений с сохранением ID пересланных сообщений ## Структура БД (миграция 005): - chat_settings: настройки чата (режим, ID канала, глобальный бан) - banned_users: история банов с причинами и информацией о модераторе - chat_messages: история сообщений с типами, файлами и картой доставки (JSONB) ## Сервисы: - ChatSettingsService: управление настройками чата - BanService: управление банами пользователей - ChatMessageService: работа с историей сообщений - ChatPermissionService: проверка прав на отправку сообщений ## Обработчики: - chat_handlers.py: обработка сообщений пользователей (7 типов контента) - admin_chat_handlers.py: админские команды управления чатом ## Админские команды: - /chat_mode - переключение режима (broadcast/forward) - /set_forward <chat_id> - установка ID канала для пересылки - /ban <user_id> [причина] - бан пользователя - /unban <user_id> - разбан пользователя - /banlist - список забаненных - /global_ban - включение/выключение глобального бана - /delete_msg - удаление сообщения (ответ на сообщение) - /chat_stats - статистика чата ## Документация: - docs/CHAT_SYSTEM.md: полное описание системы с примерами использования Изменено файлов: 7 (2 modified, 5 new) - main.py: подключены chat_router и admin_chat_router - src/core/models.py: добавлены модели ChatSettings, BannedUser, ChatMessage - migrations/versions/005_add_chat_system.py: миграция создания таблиц - src/core/chat_services.py: сервисный слой для чата (267 строк) - src/handlers/chat_handlers.py: обработчики сообщений (447 строк) - src/handlers/admin_chat_handlers.py: админские команды (369 строк) - docs/CHAT_SYSTEM.md: документация (390 строк)
This commit is contained in:
355
docs/CHAT_SYSTEM.md
Normal file
355
docs/CHAT_SYSTEM.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# Система чата пользователей
|
||||
|
||||
## Описание
|
||||
|
||||
Система чата позволяет пользователям общаться между собой через бота с двумя режимами работы:
|
||||
- **Broadcast (Рассылка)** - сообщения пользователей рассылаются всем остальным пользователям
|
||||
- **Forward (Пересылка)** - сообщения пользователей пересылаются в указанную группу/канал
|
||||
|
||||
## Режимы работы
|
||||
|
||||
### Режим Broadcast (Рассылка всем)
|
||||
|
||||
В этом режиме сообщения от одного пользователя автоматически рассылаются всем остальным активным пользователям бота.
|
||||
|
||||
**Особенности:**
|
||||
- Отправитель не получает копию своего сообщения
|
||||
- Сообщение доставляется только активным пользователям (is_active=True)
|
||||
- В базу сохраняется статистика доставки (кому доставлено, кому нет)
|
||||
- ID отправленных сообщений сохраняются в `forwarded_message_ids` (JSONB)
|
||||
|
||||
**Пример работы:**
|
||||
1. Пользователь А отправляет фото с текстом "Привет всем!"
|
||||
2. Бот копирует это сообщение пользователям B, C, D...
|
||||
3. В базу сохраняется: `{telegram_id_B: msg_id_1, telegram_id_C: msg_id_2, ...}`
|
||||
4. Пользователю А показывается статистика: "✅ Сообщение разослано! 📤 Доставлено: 15, ❌ Не доставлено: 2"
|
||||
|
||||
### Режим Forward (Пересылка в канал)
|
||||
|
||||
В этом режиме сообщения от пользователей пересылаются в указанную группу или канал.
|
||||
|
||||
**Особенности:**
|
||||
- Бот должен быть администратором канала/группы с правом публикации
|
||||
- Сохраняется оригинальное авторство сообщения (пересылка, а не копия)
|
||||
- ID канала хранится в `chat_settings.forward_chat_id`
|
||||
- В базу сохраняется ID сообщения в канале
|
||||
|
||||
**Пример работы:**
|
||||
1. Пользователь отправляет видео
|
||||
2. Бот пересылает это видео в канал (сохраняя имя отправителя)
|
||||
3. В базу сохраняется: `{channel: message_id_in_channel}`
|
||||
4. Пользователю показывается: "✅ Сообщение переслано в канал"
|
||||
|
||||
## Поддерживаемые типы сообщений
|
||||
|
||||
Система поддерживает все основные типы контента:
|
||||
|
||||
| Тип | Поле `message_type` | Поле `file_id` | Описание |
|
||||
|-----|---------------------|----------------|----------|
|
||||
| Текст | `text` | NULL | Обычное текстовое сообщение |
|
||||
| Фото | `photo` | file_id | Изображение (сохраняется самое большое) |
|
||||
| Видео | `video` | file_id | Видео файл |
|
||||
| Документ | `document` | file_id | Файл любого типа |
|
||||
| GIF | `animation` | file_id | Анимированное изображение |
|
||||
| Стикер | `sticker` | file_id | Стикер из набора |
|
||||
| Голосовое | `voice` | file_id | Голосовое сообщение |
|
||||
|
||||
**Примечание:** Для всех типов кроме `text` и `sticker` может быть указан `caption` (подпись), который сохраняется в поле `text`.
|
||||
|
||||
## Система банов
|
||||
|
||||
### Личный бан пользователя
|
||||
|
||||
Администратор может забанить конкретного пользователя:
|
||||
|
||||
```
|
||||
/ban 123456789 Спам в чате
|
||||
/ban (ответ на сообщение) Нарушение правил
|
||||
```
|
||||
|
||||
**Эффекты:**
|
||||
- Пользователь не может отправлять сообщения
|
||||
- При попытке отправки получает: "❌ Вы заблокированы и не можете отправлять сообщения"
|
||||
- Запись добавляется в таблицу `banned_users` с `is_active=true`
|
||||
|
||||
**Разблокировка:**
|
||||
```
|
||||
/unban 123456789
|
||||
/unban (ответ на сообщение)
|
||||
```
|
||||
|
||||
### Глобальный бан чата
|
||||
|
||||
Администратор может временно закрыть весь чат:
|
||||
|
||||
```
|
||||
/global_ban
|
||||
```
|
||||
|
||||
**Эффекты:**
|
||||
- Все пользователи (кроме админов) не могут писать
|
||||
- При попытке отправки: "❌ Чат временно закрыт администратором"
|
||||
- Флаг `chat_settings.global_ban` устанавливается в `true`
|
||||
|
||||
**Открытие чата:**
|
||||
```
|
||||
/global_ban (повторно - переключение)
|
||||
```
|
||||
|
||||
## Модерация сообщений
|
||||
|
||||
### Удаление сообщений
|
||||
|
||||
Администратор может удалить сообщение из всех чатов:
|
||||
|
||||
```
|
||||
/delete_msg (ответ на сообщение)
|
||||
```
|
||||
|
||||
**Процесс:**
|
||||
1. Сообщение помечается как удаленное в БД (`is_deleted=true`)
|
||||
2. Сохраняется кто удалил (`deleted_by`) и когда (`deleted_at`)
|
||||
3. Бот пытается удалить сообщение у всех пользователей, используя `forwarded_message_ids`
|
||||
4. Показывается статистика: "✅ Удалено у 12 пользователей"
|
||||
|
||||
**Важно:** Удаление возможно только если сообщение было сохранено в БД и есть `forwarded_message_ids`.
|
||||
|
||||
## Админские команды
|
||||
|
||||
### /chat_mode
|
||||
Переключение режима работы чата.
|
||||
|
||||
**Интерфейс:** Inline-клавиатура с выбором режима.
|
||||
|
||||
**Пример использования:**
|
||||
```
|
||||
/chat_mode
|
||||
→ Показывается меню выбора режима
|
||||
→ Нажимаем "📢 Рассылка всем"
|
||||
→ Режим изменен
|
||||
```
|
||||
|
||||
### /set_forward <chat_id>
|
||||
Установить ID канала/группы для пересылки.
|
||||
|
||||
**Как узнать chat_id:**
|
||||
1. Добавьте бота в канал/группу
|
||||
2. Напишите любое сообщение в канале
|
||||
3. Перешлите его боту @userinfobot
|
||||
4. Он покажет chat_id (например: -1001234567890)
|
||||
|
||||
**Пример:**
|
||||
```
|
||||
/set_forward -1001234567890
|
||||
→ ID канала для пересылки установлен!
|
||||
```
|
||||
|
||||
### /ban <user_id> [причина]
|
||||
Забанить пользователя.
|
||||
|
||||
**Способы использования:**
|
||||
1. Ответить на сообщение: `/ban Спам`
|
||||
2. Указать ID: `/ban 123456789 Нарушение правил`
|
||||
|
||||
### /unban <user_id>
|
||||
Разбанить пользователя.
|
||||
|
||||
**Способы использования:**
|
||||
1. Ответить на сообщение: `/unban`
|
||||
2. Указать ID: `/unban 123456789`
|
||||
|
||||
### /banlist
|
||||
Показать список всех забаненных пользователей.
|
||||
|
||||
**Формат вывода:**
|
||||
```
|
||||
🚫 Забаненные пользователи
|
||||
|
||||
👤 Иван Иванов (123456789)
|
||||
🔨 Забанил: Админ
|
||||
📝 Причина: Спам
|
||||
📅 Дата: 15.01.2025 14:30
|
||||
|
||||
👤 Петр Петров (987654321)
|
||||
🔨 Забанил: Админ
|
||||
📅 Дата: 14.01.2025 12:00
|
||||
```
|
||||
|
||||
### /global_ban
|
||||
Включить/выключить глобальный бан чата (переключатель).
|
||||
|
||||
**Статусы:**
|
||||
- 🔇 Включен - только админы могут писать
|
||||
- 🔊 Выключен - все могут писать
|
||||
|
||||
### /delete_msg
|
||||
Удалить сообщение (ответ на сообщение).
|
||||
|
||||
**Требует:** Ответить на сообщение, которое нужно удалить.
|
||||
|
||||
### /chat_stats
|
||||
Показать статистику чата.
|
||||
|
||||
**Информация:**
|
||||
- Текущий режим работы
|
||||
- Статус глобального бана
|
||||
- Количество забаненных пользователей
|
||||
- Количество сообщений за последнее время
|
||||
- ID канала (если установлен)
|
||||
|
||||
## База данных
|
||||
|
||||
### Таблица chat_settings
|
||||
|
||||
Одна строка с глобальными настройками чата:
|
||||
|
||||
```sql
|
||||
id = 1 (всегда)
|
||||
mode = 'broadcast' | 'forward'
|
||||
forward_chat_id = '-1001234567890' (для режима forward)
|
||||
global_ban = true | false
|
||||
```
|
||||
|
||||
### Таблица banned_users
|
||||
|
||||
История банов пользователей:
|
||||
|
||||
```sql
|
||||
id - уникальный ID бана
|
||||
user_id - FK на users.id
|
||||
telegram_id - Telegram ID пользователя
|
||||
banned_by - FK на users.id (кто забанил)
|
||||
reason - текстовая причина (nullable)
|
||||
banned_at - timestamp бана
|
||||
is_active - true/false (активен ли бан)
|
||||
```
|
||||
|
||||
**Примечание:** При разбане `is_active` меняется на `false`, но запись не удаляется (история).
|
||||
|
||||
### Таблица chat_messages
|
||||
|
||||
История всех отправленных сообщений:
|
||||
|
||||
```sql
|
||||
id - уникальный ID сообщения
|
||||
user_id - FK на users.id (отправитель)
|
||||
telegram_message_id - ID сообщения в Telegram
|
||||
message_type - text/photo/video/document/animation/sticker/voice
|
||||
text - текст или caption (nullable)
|
||||
file_id - file_id медиа (nullable)
|
||||
forwarded_message_ids - JSONB с картой доставки
|
||||
is_deleted - помечено ли как удаленное
|
||||
deleted_by - FK на users.id (кто удалил, nullable)
|
||||
deleted_at - timestamp удаления (nullable)
|
||||
created_at - timestamp отправки
|
||||
```
|
||||
|
||||
**Формат forwarded_message_ids:**
|
||||
```json
|
||||
// Режим broadcast:
|
||||
{
|
||||
"123456789": 12345, // telegram_id: message_id
|
||||
"987654321": 12346,
|
||||
"555555555": 12347
|
||||
}
|
||||
|
||||
// Режим forward:
|
||||
{
|
||||
"channel": 54321 // ключ "channel", значение - ID сообщения в канале
|
||||
}
|
||||
```
|
||||
|
||||
## Примеры использования
|
||||
|
||||
### Настройка режима broadcast
|
||||
|
||||
1. Админ: `/chat_mode` → выбирает "📢 Рассылка всем"
|
||||
2. Пользователь А пишет: "Привет всем!"
|
||||
3. Пользователи B, C, D получают это сообщение
|
||||
4. Пользователь А видит: "✅ Сообщение разослано! 📤 Доставлено: 3"
|
||||
|
||||
### Настройка режима forward
|
||||
|
||||
1. Админ создает канал и добавляет бота как админа
|
||||
2. Админ узнает chat_id канала (например: -1001234567890)
|
||||
3. Админ: `/set_forward -1001234567890`
|
||||
4. Админ: `/chat_mode` → выбирает "➡️ Пересылка в канал"
|
||||
5. Пользователь пишет сообщение → оно появляется в канале
|
||||
|
||||
### Бан пользователя за спам
|
||||
|
||||
1. Пользователь отправляет спам
|
||||
2. Админ отвечает на его сообщение: `/ban Спам в чате`
|
||||
3. Пользователь забанен, попытки отправить сообщение блокируются
|
||||
4. Админ: `/banlist` - видит список банов
|
||||
5. Админ: `/unban` (ответ на сообщение) - разбан
|
||||
|
||||
### Временное закрытие чата
|
||||
|
||||
1. Админ: `/global_ban`
|
||||
2. Все пользователи видят: "❌ Чат временно закрыт администратором"
|
||||
3. Только админы могут писать
|
||||
4. Админ: `/global_ban` (повторно) - чат открыт
|
||||
|
||||
### Удаление неприемлемого контента
|
||||
|
||||
1. Пользователь отправил неприемлемое фото
|
||||
2. Фото разослано всем (режим broadcast)
|
||||
3. Админ отвечает на это сообщение: `/delete_msg`
|
||||
4. Бот удаляет фото у всех пользователей, кому оно было отправлено
|
||||
5. В БД сообщение помечается как удаленное
|
||||
|
||||
## Технические детали
|
||||
|
||||
### Порядок подключения роутеров
|
||||
|
||||
```python
|
||||
dp.include_router(registration_router) # Первым
|
||||
dp.include_router(admin_account_router)
|
||||
dp.include_router(admin_chat_router) # До chat_router!
|
||||
dp.include_router(redraw_router)
|
||||
dp.include_router(account_router)
|
||||
dp.include_router(chat_router) # ПОСЛЕДНИМ (ловит все сообщения)
|
||||
dp.include_router(router)
|
||||
dp.include_router(admin_router)
|
||||
```
|
||||
|
||||
**Важно:** `chat_router` должен быть последним, так как он ловит ВСЕ типы сообщений (text, photo, video и т.д.). Если поставить его раньше, он будет перехватывать команды и сообщения, предназначенные для других обработчиков.
|
||||
|
||||
### Проверка прав
|
||||
|
||||
```python
|
||||
can_send, reason = await ChatPermissionService.can_send_message(
|
||||
session,
|
||||
telegram_id=user.telegram_id,
|
||||
is_admin=is_admin(user.telegram_id)
|
||||
)
|
||||
```
|
||||
|
||||
**Логика проверки:**
|
||||
1. Если пользователь админ → всегда `can_send=True`
|
||||
2. Если включен global_ban → `can_send=False`
|
||||
3. Если пользователь забанен → `can_send=False`
|
||||
4. Иначе → `can_send=True`
|
||||
|
||||
### Миграция 005
|
||||
|
||||
При запуске миграции создаются 3 таблицы и вставляется начальная запись:
|
||||
|
||||
```sql
|
||||
INSERT INTO chat_settings (id, mode, global_ban)
|
||||
VALUES (1, 'broadcast', false);
|
||||
```
|
||||
|
||||
Эта запись будет использоваться всегда (единственная строка в таблице).
|
||||
|
||||
## Возможные улучшения
|
||||
|
||||
1. **Фильтрация контента** - автоматическая проверка на мат, спам, ссылки
|
||||
2. **Лимиты** - ограничение количества сообщений в минуту/час
|
||||
3. **Ответы на сообщения** - возможность отвечать на конкретное сообщение пользователя
|
||||
4. **Редактирование** - изменение отправленных сообщений
|
||||
5. **Реакции** - лайки/дизлайки на сообщения
|
||||
6. **Каналы** - разделение чата на темы/каналы
|
||||
7. **История** - просмотр истории сообщений через команду
|
||||
8. **Поиск** - поиск по истории сообщений
|
||||
4
main.py
4
main.py
@@ -22,6 +22,8 @@ from src.handlers.account_handlers import account_router
|
||||
from src.handlers.registration_handlers import router as registration_router
|
||||
from src.handlers.admin_account_handlers import router as admin_account_router
|
||||
from src.handlers.redraw_handlers import router as redraw_router
|
||||
from src.handlers.chat_handlers import router as chat_router
|
||||
from src.handlers.admin_chat_handlers import router as admin_chat_router
|
||||
from src.utils.async_decorators import (
|
||||
async_user_action, admin_async_action, db_operation,
|
||||
TaskManagerMiddleware, shutdown_task_manager,
|
||||
@@ -1007,8 +1009,10 @@ async def main():
|
||||
# Подключение роутеров
|
||||
dp.include_router(registration_router) # Роутер регистрации (первый)
|
||||
dp.include_router(admin_account_router) # Роутер админских команд для счетов
|
||||
dp.include_router(admin_chat_router) # Роутер админских команд чата (до обычных обработчиков)
|
||||
dp.include_router(redraw_router) # Роутер повторного розыгрыша
|
||||
dp.include_router(account_router) # Роутер для работы со счетами
|
||||
dp.include_router(chat_router) # Роутер чата пользователей (ПОСЛЕДНИМ!)
|
||||
dp.include_router(router)
|
||||
dp.include_router(admin_router)
|
||||
|
||||
|
||||
91
migrations/versions/005_add_chat_system.py
Normal file
91
migrations/versions/005_add_chat_system.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Add chat system tables
|
||||
|
||||
Revision ID: 005
|
||||
Revises: 004
|
||||
Create Date: 2025-11-16 14:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '005'
|
||||
down_revision = '004'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Таблица настроек чата
|
||||
op.create_table(
|
||||
'chat_settings',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('mode', sa.String(), nullable=False, server_default='broadcast'), # broadcast или forward
|
||||
sa.Column('forward_chat_id', sa.String(), nullable=True), # ID группы/канала для пересылки
|
||||
sa.Column('global_ban', sa.Boolean(), nullable=False, server_default='false'), # Глобальный бан чата
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Вставляем дефолтные настройки
|
||||
op.execute(
|
||||
"INSERT INTO chat_settings (id, mode, global_ban) VALUES (1, 'broadcast', false)"
|
||||
)
|
||||
|
||||
# Таблица забаненных пользователей
|
||||
op.create_table(
|
||||
'banned_users',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False), # ID пользователя в системе
|
||||
sa.Column('telegram_id', sa.BigInteger(), nullable=False), # Telegram ID
|
||||
sa.Column('banned_by', sa.Integer(), nullable=False), # ID админа
|
||||
sa.Column('reason', sa.Text(), nullable=True), # Причина бана
|
||||
sa.Column('banned_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), # Активен ли бан
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['banned_by'], ['users.id'], ondelete='SET NULL')
|
||||
)
|
||||
|
||||
# Индексы для быстрого поиска
|
||||
op.create_index('ix_banned_users_telegram_id', 'banned_users', ['telegram_id'])
|
||||
op.create_index('ix_banned_users_is_active', 'banned_users', ['is_active'])
|
||||
|
||||
# Таблица сообщений чата (для хранения истории и модерации)
|
||||
op.create_table(
|
||||
'chat_messages',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False), # Отправитель
|
||||
sa.Column('telegram_message_id', sa.Integer(), nullable=False), # ID сообщения в Telegram
|
||||
sa.Column('message_type', sa.String(), nullable=False), # text, photo, video, document, etc.
|
||||
sa.Column('text', sa.Text(), nullable=True), # Текст сообщения
|
||||
sa.Column('file_id', sa.String(), nullable=True), # ID файла в Telegram
|
||||
sa.Column('forwarded_message_ids', postgresql.JSONB(), nullable=True), # Список ID пересланных сообщений
|
||||
sa.Column('is_deleted', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('deleted_by', sa.Integer(), nullable=True), # Кто удалил
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['deleted_by'], ['users.id'], ondelete='SET NULL')
|
||||
)
|
||||
|
||||
# Индексы
|
||||
op.create_index('ix_chat_messages_user_id', 'chat_messages', ['user_id'])
|
||||
op.create_index('ix_chat_messages_created_at', 'chat_messages', ['created_at'])
|
||||
op.create_index('ix_chat_messages_is_deleted', 'chat_messages', ['is_deleted'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('ix_chat_messages_is_deleted', table_name='chat_messages')
|
||||
op.drop_index('ix_chat_messages_created_at', table_name='chat_messages')
|
||||
op.drop_index('ix_chat_messages_user_id', table_name='chat_messages')
|
||||
op.drop_table('chat_messages')
|
||||
|
||||
op.drop_index('ix_banned_users_is_active', table_name='banned_users')
|
||||
op.drop_index('ix_banned_users_telegram_id', table_name='banned_users')
|
||||
op.drop_table('banned_users')
|
||||
|
||||
op.drop_table('chat_settings')
|
||||
270
src/core/chat_services.py
Normal file
270
src/core/chat_services.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""Сервисы для системы чата"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, or_, update, delete
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .models import ChatSettings, BannedUser, ChatMessage, User
|
||||
|
||||
|
||||
class ChatSettingsService:
|
||||
"""Сервис управления настройками чата"""
|
||||
|
||||
@staticmethod
|
||||
async def get_settings(session: AsyncSession) -> Optional[ChatSettings]:
|
||||
"""Получить текущие настройки чата"""
|
||||
result = await session.execute(
|
||||
select(ChatSettings).where(ChatSettings.id == 1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def get_or_create_settings(session: AsyncSession) -> ChatSettings:
|
||||
"""Получить или создать настройки чата"""
|
||||
settings = await ChatSettingsService.get_settings(session)
|
||||
if not settings:
|
||||
settings = ChatSettings(id=1, mode='broadcast', global_ban=False)
|
||||
session.add(settings)
|
||||
await session.commit()
|
||||
await session.refresh(settings)
|
||||
return settings
|
||||
|
||||
@staticmethod
|
||||
async def set_mode(session: AsyncSession, mode: str) -> ChatSettings:
|
||||
"""Установить режим работы чата (broadcast/forward)"""
|
||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||
settings.mode = mode
|
||||
settings.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(settings)
|
||||
return settings
|
||||
|
||||
@staticmethod
|
||||
async def set_forward_chat(session: AsyncSession, chat_id: str) -> ChatSettings:
|
||||
"""Установить ID группы/канала для пересылки"""
|
||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||
settings.forward_chat_id = chat_id
|
||||
settings.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(settings)
|
||||
return settings
|
||||
|
||||
@staticmethod
|
||||
async def set_global_ban(session: AsyncSession, enabled: bool) -> ChatSettings:
|
||||
"""Включить/выключить глобальный бан чата"""
|
||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||
settings.global_ban = enabled
|
||||
settings.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(settings)
|
||||
return settings
|
||||
|
||||
|
||||
class BanService:
|
||||
"""Сервис управления банами пользователей"""
|
||||
|
||||
@staticmethod
|
||||
async def is_banned(session: AsyncSession, telegram_id: int) -> bool:
|
||||
"""Проверить забанен ли пользователь"""
|
||||
result = await session.execute(
|
||||
select(BannedUser).where(
|
||||
and_(
|
||||
BannedUser.telegram_id == telegram_id,
|
||||
BannedUser.is_active == True
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
@staticmethod
|
||||
async def ban_user(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
telegram_id: int,
|
||||
banned_by: int,
|
||||
reason: Optional[str] = None
|
||||
) -> BannedUser:
|
||||
"""Забанить пользователя"""
|
||||
# Проверяем есть ли уже активный бан
|
||||
existing_ban = await session.execute(
|
||||
select(BannedUser).where(
|
||||
and_(
|
||||
BannedUser.telegram_id == telegram_id,
|
||||
BannedUser.is_active == True
|
||||
)
|
||||
)
|
||||
)
|
||||
existing = existing_ban.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
# Обновляем причину
|
||||
existing.reason = reason
|
||||
existing.banned_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(existing)
|
||||
return existing
|
||||
|
||||
# Создаем новый бан
|
||||
ban = BannedUser(
|
||||
user_id=user_id,
|
||||
telegram_id=telegram_id,
|
||||
banned_by=banned_by,
|
||||
reason=reason
|
||||
)
|
||||
session.add(ban)
|
||||
await session.commit()
|
||||
await session.refresh(ban)
|
||||
return ban
|
||||
|
||||
@staticmethod
|
||||
async def unban_user(session: AsyncSession, telegram_id: int) -> bool:
|
||||
"""Разбанить пользователя"""
|
||||
result = await session.execute(
|
||||
update(BannedUser)
|
||||
.where(
|
||||
and_(
|
||||
BannedUser.telegram_id == telegram_id,
|
||||
BannedUser.is_active == True
|
||||
)
|
||||
)
|
||||
.values(is_active=False)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def get_banned_users(session: AsyncSession, active_only: bool = True) -> List[BannedUser]:
|
||||
"""Получить список забаненных пользователей"""
|
||||
query = select(BannedUser).options(
|
||||
selectinload(BannedUser.user),
|
||||
selectinload(BannedUser.admin)
|
||||
)
|
||||
|
||||
if active_only:
|
||||
query = query.where(BannedUser.is_active == True)
|
||||
|
||||
result = await session.execute(query.order_by(BannedUser.banned_at.desc()))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
class ChatMessageService:
|
||||
"""Сервис работы с сообщениями чата"""
|
||||
|
||||
@staticmethod
|
||||
async def save_message(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
telegram_message_id: int,
|
||||
message_type: str,
|
||||
text: Optional[str] = None,
|
||||
file_id: Optional[str] = None,
|
||||
forwarded_ids: Optional[Dict[str, int]] = None
|
||||
) -> ChatMessage:
|
||||
"""Сохранить сообщение в историю"""
|
||||
message = ChatMessage(
|
||||
user_id=user_id,
|
||||
telegram_message_id=telegram_message_id,
|
||||
message_type=message_type,
|
||||
text=text,
|
||||
file_id=file_id,
|
||||
forwarded_message_ids=forwarded_ids
|
||||
)
|
||||
session.add(message)
|
||||
await session.commit()
|
||||
await session.refresh(message)
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
async def get_message(session: AsyncSession, message_id: int) -> Optional[ChatMessage]:
|
||||
"""Получить сообщение по ID"""
|
||||
result = await session.execute(
|
||||
select(ChatMessage)
|
||||
.options(selectinload(ChatMessage.sender))
|
||||
.where(ChatMessage.id == message_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def get_user_messages(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
limit: int = 50,
|
||||
include_deleted: bool = False
|
||||
) -> List[ChatMessage]:
|
||||
"""Получить сообщения пользователя"""
|
||||
query = select(ChatMessage).where(ChatMessage.user_id == user_id)
|
||||
|
||||
if not include_deleted:
|
||||
query = query.where(ChatMessage.is_deleted == False)
|
||||
|
||||
query = query.order_by(ChatMessage.created_at.desc()).limit(limit)
|
||||
|
||||
result = await session.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def delete_message(
|
||||
session: AsyncSession,
|
||||
message_id: int,
|
||||
deleted_by: int
|
||||
) -> bool:
|
||||
"""Пометить сообщение как удаленное"""
|
||||
result = await session.execute(
|
||||
update(ChatMessage)
|
||||
.where(ChatMessage.id == message_id)
|
||||
.values(
|
||||
is_deleted=True,
|
||||
deleted_by=deleted_by,
|
||||
deleted_at=datetime.now(timezone.utc)
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def get_recent_messages(
|
||||
session: AsyncSession,
|
||||
limit: int = 100,
|
||||
include_deleted: bool = False
|
||||
) -> List[ChatMessage]:
|
||||
"""Получить последние сообщения чата"""
|
||||
query = select(ChatMessage).options(selectinload(ChatMessage.sender))
|
||||
|
||||
if not include_deleted:
|
||||
query = query.where(ChatMessage.is_deleted == False)
|
||||
|
||||
query = query.order_by(ChatMessage.created_at.desc()).limit(limit)
|
||||
|
||||
result = await session.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
class ChatPermissionService:
|
||||
"""Сервис проверки прав на отправку сообщений"""
|
||||
|
||||
@staticmethod
|
||||
async def can_send_message(
|
||||
session: AsyncSession,
|
||||
telegram_id: int,
|
||||
is_admin: bool = False
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Проверить может ли пользователь отправлять сообщения
|
||||
Возвращает (разрешено, причина_отказа)
|
||||
"""
|
||||
# Админы всегда могут отправлять
|
||||
if is_admin:
|
||||
return True, None
|
||||
|
||||
# Проверяем глобальный бан
|
||||
settings = await ChatSettingsService.get_settings(session)
|
||||
if settings and settings.global_ban:
|
||||
return False, "Чат временно закрыт администратором"
|
||||
|
||||
# Проверяем личный бан
|
||||
is_banned = await BanService.is_banned(session, telegram_id)
|
||||
if is_banned:
|
||||
return False, "Вы заблокированы и не можете отправлять сообщения"
|
||||
|
||||
return True, None
|
||||
@@ -157,3 +157,62 @@ class Winner(Base):
|
||||
if self.account_number:
|
||||
return f"<Winner(lottery_id={self.lottery_id}, account={self.account_number}, place={self.place})>"
|
||||
return f"<Winner(lottery_id={self.lottery_id}, user_id={self.user_id}, place={self.place})>"
|
||||
|
||||
|
||||
class ChatSettings(Base):
|
||||
"""Настройки системы чата"""
|
||||
__tablename__ = "chat_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
mode = Column(String(20), nullable=False, default='broadcast') # broadcast или forward
|
||||
forward_chat_id = Column(String(50), nullable=True) # ID группы/канала для пересылки
|
||||
global_ban = Column(Boolean, default=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))
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ChatSettings(mode={self.mode}, global_ban={self.global_ban})>"
|
||||
|
||||
|
||||
class BannedUser(Base):
|
||||
"""Забаненные пользователи (не могут отправлять сообщения)"""
|
||||
__tablename__ = "banned_users"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
telegram_id = Column(Integer, nullable=False, index=True)
|
||||
banned_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
reason = Column(Text, nullable=True)
|
||||
banned_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
is_active = Column(Boolean, default=True, index=True) # Активен ли бан
|
||||
|
||||
# Связи
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
admin = relationship("User", foreign_keys=[banned_by])
|
||||
|
||||
def __repr__(self):
|
||||
return f"<BannedUser(telegram_id={self.telegram_id}, is_active={self.is_active})>"
|
||||
|
||||
|
||||
class ChatMessage(Base):
|
||||
"""История сообщений чата (для модерации)"""
|
||||
__tablename__ = "chat_messages"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
telegram_message_id = Column(Integer, nullable=False)
|
||||
message_type = Column(String(20), nullable=False) # text, photo, video, document, animation, sticker, voice, etc.
|
||||
text = Column(Text, nullable=True) # Текст сообщения
|
||||
file_id = Column(String(255), nullable=True) # ID файла в Telegram
|
||||
forwarded_message_ids = Column(JSON, nullable=True) # Список telegram_message_id пересланных сообщений {"user_telegram_id": message_id}
|
||||
is_deleted = Column(Boolean, default=False, index=True)
|
||||
deleted_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True)
|
||||
|
||||
# Связи
|
||||
sender = relationship("User", foreign_keys=[user_id])
|
||||
moderator = relationship("User", foreign_keys=[deleted_by])
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ChatMessage(id={self.id}, user_id={self.user_id}, type={self.message_type})>"
|
||||
374
src/handlers/admin_chat_handlers.py
Normal file
374
src/handlers/admin_chat_handlers.py
Normal file
@@ -0,0 +1,374 @@
|
||||
"""Админские обработчики для управления чатом"""
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.filters import Command
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.core.chat_services import (
|
||||
ChatSettingsService,
|
||||
BanService,
|
||||
ChatMessageService
|
||||
)
|
||||
from src.core.services import UserService
|
||||
from database import get_session
|
||||
from config import ADMIN_IDS
|
||||
|
||||
|
||||
router = Router(name='admin_chat_router')
|
||||
|
||||
|
||||
def is_admin(user_id: int) -> bool:
|
||||
"""Проверка является ли пользователь админом"""
|
||||
return user_id in ADMIN_IDS
|
||||
|
||||
|
||||
def get_chat_mode_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Клавиатура выбора режима чата"""
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📢 Рассылка всем", callback_data="chat_mode:broadcast"),
|
||||
InlineKeyboardButton(text="➡️ Пересылка в канал", callback_data="chat_mode:forward")
|
||||
],
|
||||
[InlineKeyboardButton(text="❌ Закрыть", callback_data="close_menu")]
|
||||
])
|
||||
|
||||
|
||||
@router.message(Command("chat_mode"))
|
||||
async def cmd_chat_mode(message: Message):
|
||||
"""Команда управления режимом чата"""
|
||||
if not is_admin(message.from_user.id):
|
||||
await message.answer("❌ У вас нет прав для выполнения этой команды")
|
||||
return
|
||||
|
||||
async for session in get_session():
|
||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||
|
||||
mode_text = "📢 Рассылка всем пользователям" if settings.mode == 'broadcast' else "➡️ Пересылка в канал"
|
||||
|
||||
await message.answer(
|
||||
f"🎛 <b>Управление режимом чата</b>\n\n"
|
||||
f"Текущий режим: {mode_text}\n\n"
|
||||
f"Выберите режим работы:",
|
||||
reply_markup=get_chat_mode_keyboard(),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("chat_mode:"))
|
||||
async def process_chat_mode(callback: CallbackQuery):
|
||||
"""Обработка выбора режима чата"""
|
||||
if not is_admin(callback.from_user.id):
|
||||
await callback.answer("❌ У вас нет прав", show_alert=True)
|
||||
return
|
||||
|
||||
mode = callback.data.split(":")[1]
|
||||
|
||||
async for session in get_session():
|
||||
settings = await ChatSettingsService.set_mode(session, mode)
|
||||
|
||||
mode_text = "📢 Рассылка всем пользователям" if mode == 'broadcast' else "➡️ Пересылка в канал"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Режим чата изменен!\n\n"
|
||||
f"Новый режим: {mode_text}",
|
||||
reply_markup=None
|
||||
)
|
||||
|
||||
await callback.answer("✅ Режим изменен")
|
||||
|
||||
|
||||
@router.message(Command("set_forward"))
|
||||
async def cmd_set_forward(message: Message):
|
||||
"""Установить ID канала для пересылки"""
|
||||
if not is_admin(message.from_user.id):
|
||||
await message.answer("❌ У вас нет прав для выполнения этой команды")
|
||||
return
|
||||
|
||||
args = message.text.split(maxsplit=1)
|
||||
if len(args) < 2:
|
||||
await message.answer(
|
||||
"📝 <b>Использование:</b>\n"
|
||||
"/set_forward <chat_id>\n\n"
|
||||
"Пример: /set_forward -1001234567890\n\n"
|
||||
"💡 Чтобы узнать ID канала/группы:\n"
|
||||
"1. Добавьте бота в канал/группу\n"
|
||||
"2. Напишите любое сообщение\n"
|
||||
"3. Перешлите его боту @userinfobot",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
chat_id = args[1].strip()
|
||||
|
||||
async for session in get_session():
|
||||
settings = await ChatSettingsService.set_forward_chat(session, chat_id)
|
||||
|
||||
await message.answer(
|
||||
f"✅ ID канала для пересылки установлен!\n\n"
|
||||
f"Chat ID: <code>{chat_id}</code>\n\n"
|
||||
f"Теперь переключитесь в режим пересылки командой /chat_mode",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("global_ban"))
|
||||
async def cmd_global_ban(message: Message):
|
||||
"""Включить/выключить глобальный бан чата"""
|
||||
if not is_admin(message.from_user.id):
|
||||
await message.answer("❌ У вас нет прав для выполнения этой команды")
|
||||
return
|
||||
|
||||
async for session in get_session():
|
||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||
|
||||
# Переключаем состояние
|
||||
new_state = not settings.global_ban
|
||||
settings = await ChatSettingsService.set_global_ban(session, new_state)
|
||||
|
||||
if new_state:
|
||||
await message.answer(
|
||||
"🔇 <b>Глобальный бан включен</b>\n\n"
|
||||
"Теперь только администраторы могут отправлять сообщения в чат",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
"🔊 <b>Глобальный бан выключен</b>\n\n"
|
||||
"Все пользователи снова могут отправлять сообщения",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("ban"))
|
||||
async def cmd_ban(message: Message):
|
||||
"""Забанить пользователя"""
|
||||
if not is_admin(message.from_user.id):
|
||||
await message.answer("❌ У вас нет прав для выполнения этой команды")
|
||||
return
|
||||
|
||||
# Проверяем является ли это ответом на сообщение
|
||||
if message.reply_to_message:
|
||||
target_user_id = message.reply_to_message.from_user.id
|
||||
reason = message.text.split(maxsplit=1)[1] if len(message.text.split(maxsplit=1)) > 1 else None
|
||||
else:
|
||||
args = message.text.split(maxsplit=2)
|
||||
if len(args) < 2:
|
||||
await message.answer(
|
||||
"📝 <b>Использование:</b>\n\n"
|
||||
"1. Ответьте на сообщение пользователя: /ban [причина]\n"
|
||||
"2. Укажите ID: /ban <user_id> [причина]\n\n"
|
||||
"Пример: /ban 123456789 Спам",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
target_user_id = int(args[1])
|
||||
reason = args[2] if len(args) > 2 else None
|
||||
except ValueError:
|
||||
await message.answer("❌ Неверный ID пользователя")
|
||||
return
|
||||
|
||||
async for session in get_session():
|
||||
# Получаем пользователя
|
||||
user = await UserService.get_user_by_telegram_id(session, target_user_id)
|
||||
|
||||
if not user:
|
||||
await message.answer("❌ Пользователь не найден в базе")
|
||||
return
|
||||
|
||||
# Получаем админа
|
||||
admin = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||
|
||||
# Баним
|
||||
ban = await BanService.ban_user(
|
||||
session,
|
||||
user_id=user.id,
|
||||
telegram_id=target_user_id,
|
||||
banned_by=admin.id,
|
||||
reason=reason
|
||||
)
|
||||
|
||||
reason_text = f"\n📝 Причина: {reason}" if reason else ""
|
||||
|
||||
await message.answer(
|
||||
f"🚫 <b>Пользователь забанен</b>\n\n"
|
||||
f"👤 Пользователь: {user.name or 'Неизвестен'}\n"
|
||||
f"🆔 ID: <code>{target_user_id}</code>"
|
||||
f"{reason_text}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("unban"))
|
||||
async def cmd_unban(message: Message):
|
||||
"""Разбанить пользователя"""
|
||||
if not is_admin(message.from_user.id):
|
||||
await message.answer("❌ У вас нет прав для выполнения этой команды")
|
||||
return
|
||||
|
||||
# Проверяем является ли это ответом на сообщение
|
||||
if message.reply_to_message:
|
||||
target_user_id = message.reply_to_message.from_user.id
|
||||
else:
|
||||
args = message.text.split()
|
||||
if len(args) < 2:
|
||||
await message.answer(
|
||||
"📝 <b>Использование:</b>\n\n"
|
||||
"1. Ответьте на сообщение пользователя: /unban\n"
|
||||
"2. Укажите ID: /unban <user_id>\n\n"
|
||||
"Пример: /unban 123456789",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
target_user_id = int(args[1])
|
||||
except ValueError:
|
||||
await message.answer("❌ Неверный ID пользователя")
|
||||
return
|
||||
|
||||
async for session in get_session():
|
||||
# Разбаниваем
|
||||
success = await BanService.unban_user(session, target_user_id)
|
||||
|
||||
if success:
|
||||
await message.answer(
|
||||
f"✅ <b>Пользователь разбанен</b>\n\n"
|
||||
f"🆔 ID: <code>{target_user_id}</code>\n\n"
|
||||
f"Теперь пользователь может отправлять сообщения",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Пользователь не был забанен")
|
||||
|
||||
|
||||
@router.message(Command("banlist"))
|
||||
async def cmd_banlist(message: Message):
|
||||
"""Показать список забаненных пользователей"""
|
||||
if not is_admin(message.from_user.id):
|
||||
await message.answer("❌ У вас нет прав для выполнения этой команды")
|
||||
return
|
||||
|
||||
async for session in get_session():
|
||||
banned_users = await BanService.get_banned_users(session, active_only=True)
|
||||
|
||||
if not banned_users:
|
||||
await message.answer("📋 Список банов пуст")
|
||||
return
|
||||
|
||||
text = "🚫 <b>Забаненные пользователи</b>\n\n"
|
||||
|
||||
for ban in banned_users:
|
||||
user = ban.user
|
||||
admin = ban.admin
|
||||
|
||||
text += f"👤 {user.name or 'Неизвестен'} (<code>{ban.telegram_id}</code>)\n"
|
||||
text += f"🔨 Забанил: {admin.name if admin else 'Неизвестен'}\n"
|
||||
|
||||
if ban.reason:
|
||||
text += f"📝 Причина: {ban.reason}\n"
|
||||
|
||||
text += f"📅 Дата: {ban.banned_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
text += "\n"
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
|
||||
@router.message(Command("delete_msg"))
|
||||
async def cmd_delete_message(message: Message):
|
||||
"""Удалить сообщение из чата (пометить как удаленное)"""
|
||||
if not is_admin(message.from_user.id):
|
||||
await message.answer("❌ У вас нет прав для выполнения этой команды")
|
||||
return
|
||||
|
||||
if not message.reply_to_message:
|
||||
await message.answer(
|
||||
"📝 <b>Использование:</b>\n\n"
|
||||
"Ответьте на сообщение которое хотите удалить командой /delete_msg",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
async for session in get_session():
|
||||
# Получаем админа
|
||||
admin = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||
|
||||
# Находим сообщение в базе по telegram_message_id
|
||||
from sqlalchemy import select
|
||||
from src.core.models import ChatMessage
|
||||
|
||||
result = await session.execute(
|
||||
select(ChatMessage).where(
|
||||
ChatMessage.telegram_message_id == message.reply_to_message.message_id
|
||||
)
|
||||
)
|
||||
chat_message = result.scalar_one_or_none()
|
||||
|
||||
if not chat_message:
|
||||
await message.answer("❌ Сообщение не найдено в базе данных")
|
||||
return
|
||||
|
||||
# Помечаем как удаленное
|
||||
success = await ChatMessageService.delete_message(
|
||||
session,
|
||||
message_id=chat_message.id,
|
||||
deleted_by=admin.id
|
||||
)
|
||||
|
||||
if success:
|
||||
# Пытаемся удалить сообщение у всех пользователей
|
||||
if chat_message.forwarded_message_ids:
|
||||
deleted_count = 0
|
||||
for user_telegram_id, msg_id in chat_message.forwarded_message_ids.items():
|
||||
try:
|
||||
await message.bot.delete_message(int(user_telegram_id), msg_id)
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
print(f"Failed to delete message {msg_id} for user {user_telegram_id}: {e}")
|
||||
|
||||
await message.answer(
|
||||
f"✅ <b>Сообщение удалено</b>\n\n"
|
||||
f"🗑 Удалено у {deleted_count} пользователей",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await message.answer("✅ Сообщение помечено как удаленное")
|
||||
else:
|
||||
await message.answer("❌ Не удалось удалить сообщение")
|
||||
|
||||
|
||||
@router.message(Command("chat_stats"))
|
||||
async def cmd_chat_stats(message: Message):
|
||||
"""Статистика чата"""
|
||||
if not is_admin(message.from_user.id):
|
||||
await message.answer("❌ У вас нет прав для выполнения этой команды")
|
||||
return
|
||||
|
||||
async for session in get_session():
|
||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||
banned_users = await BanService.get_banned_users(session, active_only=True)
|
||||
recent_messages = await ChatMessageService.get_recent_messages(session, limit=100)
|
||||
|
||||
mode_text = "📢 Рассылка всем" if settings.mode == 'broadcast' else "➡️ Пересылка в канал"
|
||||
global_ban_text = "🔇 Включен" if settings.global_ban else "🔊 Выключен"
|
||||
|
||||
text = (
|
||||
f"📊 <b>Статистика чата</b>\n\n"
|
||||
f"🎛 Режим: {mode_text}\n"
|
||||
f"🚫 Глобальный бан: {global_ban_text}\n"
|
||||
f"👥 Забанено пользователей: {len(banned_users)}\n"
|
||||
f"💬 Сообщений за последнее время: {len(recent_messages)}\n"
|
||||
)
|
||||
|
||||
if settings.mode == 'forward' and settings.forward_chat_id:
|
||||
text += f"\n➡️ ID канала: <code>{settings.forward_chat_id}</code>"
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "close_menu")
|
||||
async def close_menu(callback: CallbackQuery):
|
||||
"""Закрыть меню"""
|
||||
await callback.message.delete()
|
||||
await callback.answer()
|
||||
438
src/handlers/chat_handlers.py
Normal file
438
src/handlers/chat_handlers.py
Normal file
@@ -0,0 +1,438 @@
|
||||
"""Обработчики пользовательских сообщений в чате"""
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.core.chat_services import (
|
||||
ChatSettingsService,
|
||||
ChatPermissionService,
|
||||
ChatMessageService,
|
||||
BanService
|
||||
)
|
||||
from src.core.services import UserService
|
||||
from database import get_session
|
||||
from config import ADMIN_IDS
|
||||
|
||||
|
||||
def is_admin(user_id: int) -> bool:
|
||||
"""Проверка является ли пользователь админом"""
|
||||
return user_id in ADMIN_IDS
|
||||
|
||||
|
||||
router = Router(name='chat_router')
|
||||
|
||||
|
||||
async def get_all_active_users(session: AsyncSession):
|
||||
"""Получить всех активных пользователей для рассылки"""
|
||||
users = await UserService.get_all_users(session)
|
||||
return [u for u in users if u.is_active]
|
||||
|
||||
|
||||
async def broadcast_message(message: Message, exclude_user_id: int = None):
|
||||
"""Разослать сообщение всем пользователям"""
|
||||
async for session in get_session():
|
||||
users = await get_all_active_users(session)
|
||||
|
||||
forwarded_ids = {}
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
|
||||
for user in users:
|
||||
if exclude_user_id and user.telegram_id == exclude_user_id:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Копируем сообщение пользователю
|
||||
sent_msg = await message.copy_to(user.telegram_id)
|
||||
forwarded_ids[str(user.telegram_id)] = sent_msg.message_id
|
||||
success_count += 1
|
||||
except Exception as e:
|
||||
fail_count += 1
|
||||
print(f"Failed to send message to {user.telegram_id}: {e}")
|
||||
|
||||
return forwarded_ids, success_count, fail_count
|
||||
|
||||
|
||||
async def forward_to_channel(message: Message, channel_id: str):
|
||||
"""Переслать сообщение в канал/группу"""
|
||||
try:
|
||||
# Пересылаем сообщение в канал
|
||||
sent_msg = await message.forward(channel_id)
|
||||
return True, sent_msg.message_id
|
||||
except Exception as e:
|
||||
print(f"Failed to forward message to channel {channel_id}: {e}")
|
||||
return False, None
|
||||
|
||||
|
||||
@router.message(F.text)
|
||||
async def handle_text_message(message: Message):
|
||||
"""Обработчик текстовых сообщений"""
|
||||
async for session in get_session():
|
||||
# Проверяем права на отправку
|
||||
can_send, reason = await ChatPermissionService.can_send_message(
|
||||
session,
|
||||
message.from_user.id,
|
||||
is_admin=is_admin(message.from_user.id)
|
||||
)
|
||||
|
||||
if not can_send:
|
||||
await message.answer(f"❌ {reason}")
|
||||
return
|
||||
|
||||
# Получаем настройки чата
|
||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||
|
||||
# Получаем пользователя
|
||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||
if not user:
|
||||
await message.answer("❌ Пользователь не найден")
|
||||
return
|
||||
|
||||
# Обрабатываем в зависимости от режима
|
||||
if settings.mode == 'broadcast':
|
||||
# Режим рассылки
|
||||
forwarded_ids, success, fail = await broadcast_message(message, exclude_user_id=message.from_user.id)
|
||||
|
||||
# Сохраняем сообщение в историю
|
||||
await ChatMessageService.save_message(
|
||||
session,
|
||||
user_id=user.id,
|
||||
telegram_message_id=message.message_id,
|
||||
message_type='text',
|
||||
text=message.text,
|
||||
forwarded_ids=forwarded_ids
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"✅ Сообщение разослано!\n"
|
||||
f"📤 Доставлено: {success}\n"
|
||||
f"❌ Не доставлено: {fail}"
|
||||
)
|
||||
|
||||
elif settings.mode == 'forward':
|
||||
# Режим пересылки в канал
|
||||
if not settings.forward_chat_id:
|
||||
await message.answer("❌ Канал для пересылки не настроен")
|
||||
return
|
||||
|
||||
success, channel_msg_id = await forward_to_channel(message, settings.forward_chat_id)
|
||||
|
||||
if success:
|
||||
# Сохраняем сообщение в историю
|
||||
await ChatMessageService.save_message(
|
||||
session,
|
||||
user_id=user.id,
|
||||
telegram_message_id=message.message_id,
|
||||
message_type='text',
|
||||
text=message.text,
|
||||
forwarded_ids={'channel': channel_msg_id}
|
||||
)
|
||||
|
||||
await message.answer("✅ Сообщение переслано в канал")
|
||||
else:
|
||||
await message.answer("❌ Не удалось переслать сообщение")
|
||||
|
||||
|
||||
@router.message(F.photo)
|
||||
async def handle_photo_message(message: Message):
|
||||
"""Обработчик фото"""
|
||||
async for session in get_session():
|
||||
can_send, reason = await ChatPermissionService.can_send_message(
|
||||
session,
|
||||
message.from_user.id
|
||||
)
|
||||
|
||||
if not can_send:
|
||||
await message.answer(f"❌ {reason}")
|
||||
return
|
||||
|
||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||
|
||||
if not user:
|
||||
return
|
||||
|
||||
# Получаем file_id самого большого фото
|
||||
photo = message.photo[-1]
|
||||
|
||||
if settings.mode == 'broadcast':
|
||||
forwarded_ids, success, fail = await broadcast_message(message, exclude_user_id=message.from_user.id)
|
||||
|
||||
await ChatMessageService.save_message(
|
||||
session,
|
||||
user_id=user.id,
|
||||
telegram_message_id=message.message_id,
|
||||
message_type='photo',
|
||||
text=message.caption,
|
||||
file_id=photo.file_id,
|
||||
forwarded_ids=forwarded_ids
|
||||
)
|
||||
|
||||
await message.answer(f"✅ Фото разослано: {success} получателей")
|
||||
|
||||
elif settings.mode == 'forward':
|
||||
if settings.forward_chat_id:
|
||||
success, channel_msg_id = await forward_to_channel(message, settings.forward_chat_id)
|
||||
|
||||
if success:
|
||||
await ChatMessageService.save_message(
|
||||
session,
|
||||
user_id=user.id,
|
||||
telegram_message_id=message.message_id,
|
||||
message_type='photo',
|
||||
text=message.caption,
|
||||
file_id=photo.file_id,
|
||||
forwarded_ids={'channel': channel_msg_id}
|
||||
)
|
||||
await message.answer("✅ Фото переслано в канал")
|
||||
|
||||
|
||||
@router.message(F.video)
|
||||
async def handle_video_message(message: Message):
|
||||
"""Обработчик видео"""
|
||||
async for session in get_session():
|
||||
can_send, reason = await ChatPermissionService.can_send_message(
|
||||
session,
|
||||
message.from_user.id
|
||||
)
|
||||
|
||||
if not can_send:
|
||||
await message.answer(f"❌ {reason}")
|
||||
return
|
||||
|
||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||
|
||||
if not user:
|
||||
return
|
||||
|
||||
if settings.mode == 'broadcast':
|
||||
forwarded_ids, success, fail = await broadcast_message(message, exclude_user_id=message.from_user.id)
|
||||
|
||||
await ChatMessageService.save_message(
|
||||
session,
|
||||
user_id=user.id,
|
||||
telegram_message_id=message.message_id,
|
||||
message_type='video',
|
||||
text=message.caption,
|
||||
file_id=message.video.file_id,
|
||||
forwarded_ids=forwarded_ids
|
||||
)
|
||||
|
||||
await message.answer(f"✅ Видео разослано: {success} получателей")
|
||||
|
||||
elif settings.mode == 'forward':
|
||||
if settings.forward_chat_id:
|
||||
success, channel_msg_id = await forward_to_channel(message, settings.forward_chat_id)
|
||||
|
||||
if success:
|
||||
await ChatMessageService.save_message(
|
||||
session,
|
||||
user_id=user.id,
|
||||
telegram_message_id=message.message_id,
|
||||
message_type='video',
|
||||
text=message.caption,
|
||||
file_id=message.video.file_id,
|
||||
forwarded_ids={'channel': channel_msg_id}
|
||||
)
|
||||
await message.answer("✅ Видео переслано в канал")
|
||||
|
||||
|
||||
@router.message(F.document)
|
||||
async def handle_document_message(message: Message):
|
||||
"""Обработчик документов"""
|
||||
async for session in get_session():
|
||||
can_send, reason = await ChatPermissionService.can_send_message(
|
||||
session,
|
||||
message.from_user.id
|
||||
)
|
||||
|
||||
if not can_send:
|
||||
await message.answer(f"❌ {reason}")
|
||||
return
|
||||
|
||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||
|
||||
if not user:
|
||||
return
|
||||
|
||||
if settings.mode == 'broadcast':
|
||||
forwarded_ids, success, fail = await broadcast_message(message, exclude_user_id=message.from_user.id)
|
||||
|
||||
await ChatMessageService.save_message(
|
||||
session,
|
||||
user_id=user.id,
|
||||
telegram_message_id=message.message_id,
|
||||
message_type='document',
|
||||
text=message.caption,
|
||||
file_id=message.document.file_id,
|
||||
forwarded_ids=forwarded_ids
|
||||
)
|
||||
|
||||
await message.answer(f"✅ Документ разослан: {success} получателей")
|
||||
|
||||
elif settings.mode == 'forward':
|
||||
if settings.forward_chat_id:
|
||||
success, channel_msg_id = await forward_to_channel(message, settings.forward_chat_id)
|
||||
|
||||
if success:
|
||||
await ChatMessageService.save_message(
|
||||
session,
|
||||
user_id=user.id,
|
||||
telegram_message_id=message.message_id,
|
||||
message_type='document',
|
||||
text=message.caption,
|
||||
file_id=message.document.file_id,
|
||||
forwarded_ids={'channel': channel_msg_id}
|
||||
)
|
||||
await message.answer("✅ Документ переслан в канал")
|
||||
|
||||
|
||||
@router.message(F.animation)
|
||||
async def handle_animation_message(message: Message):
|
||||
"""Обработчик GIF анимаций"""
|
||||
async for session in get_session():
|
||||
can_send, reason = await ChatPermissionService.can_send_message(
|
||||
session,
|
||||
message.from_user.id
|
||||
)
|
||||
|
||||
if not can_send:
|
||||
await message.answer(f"❌ {reason}")
|
||||
return
|
||||
|
||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||
|
||||
if not user:
|
||||
return
|
||||
|
||||
if settings.mode == 'broadcast':
|
||||
forwarded_ids, success, fail = await broadcast_message(message, exclude_user_id=message.from_user.id)
|
||||
|
||||
await ChatMessageService.save_message(
|
||||
session,
|
||||
user_id=user.id,
|
||||
telegram_message_id=message.message_id,
|
||||
message_type='animation',
|
||||
text=message.caption,
|
||||
file_id=message.animation.file_id,
|
||||
forwarded_ids=forwarded_ids
|
||||
)
|
||||
|
||||
await message.answer(f"✅ Анимация разослана: {success} получателей")
|
||||
|
||||
elif settings.mode == 'forward':
|
||||
if settings.forward_chat_id:
|
||||
success, channel_msg_id = await forward_to_channel(message, settings.forward_chat_id)
|
||||
|
||||
if success:
|
||||
await ChatMessageService.save_message(
|
||||
session,
|
||||
user_id=user.id,
|
||||
telegram_message_id=message.message_id,
|
||||
message_type='animation',
|
||||
text=message.caption,
|
||||
file_id=message.animation.file_id,
|
||||
forwarded_ids={'channel': channel_msg_id}
|
||||
)
|
||||
await message.answer("✅ Анимация переслана в канал")
|
||||
|
||||
|
||||
@router.message(F.sticker)
|
||||
async def handle_sticker_message(message: Message):
|
||||
"""Обработчик стикеров"""
|
||||
async for session in get_session():
|
||||
can_send, reason = await ChatPermissionService.can_send_message(
|
||||
session,
|
||||
message.from_user.id
|
||||
)
|
||||
|
||||
if not can_send:
|
||||
await message.answer(f"❌ {reason}")
|
||||
return
|
||||
|
||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||
|
||||
if not user:
|
||||
return
|
||||
|
||||
if settings.mode == 'broadcast':
|
||||
forwarded_ids, success, fail = await broadcast_message(message, exclude_user_id=message.from_user.id)
|
||||
|
||||
await ChatMessageService.save_message(
|
||||
session,
|
||||
user_id=user.id,
|
||||
telegram_message_id=message.message_id,
|
||||
message_type='sticker',
|
||||
file_id=message.sticker.file_id,
|
||||
forwarded_ids=forwarded_ids
|
||||
)
|
||||
|
||||
await message.answer(f"✅ Стикер разослан: {success} получателей")
|
||||
|
||||
elif settings.mode == 'forward':
|
||||
if settings.forward_chat_id:
|
||||
success, channel_msg_id = await forward_to_channel(message, settings.forward_chat_id)
|
||||
|
||||
if success:
|
||||
await ChatMessageService.save_message(
|
||||
session,
|
||||
user_id=user.id,
|
||||
telegram_message_id=message.message_id,
|
||||
message_type='sticker',
|
||||
file_id=message.sticker.file_id,
|
||||
forwarded_ids={'channel': channel_msg_id}
|
||||
)
|
||||
await message.answer("✅ Стикер переслан в канал")
|
||||
|
||||
|
||||
@router.message(F.voice)
|
||||
async def handle_voice_message(message: Message):
|
||||
"""Обработчик голосовых сообщений"""
|
||||
async for session in get_session():
|
||||
can_send, reason = await ChatPermissionService.can_send_message(
|
||||
session,
|
||||
message.from_user.id
|
||||
)
|
||||
|
||||
if not can_send:
|
||||
await message.answer(f"❌ {reason}")
|
||||
return
|
||||
|
||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||
|
||||
if not user:
|
||||
return
|
||||
|
||||
if settings.mode == 'broadcast':
|
||||
forwarded_ids, success, fail = await broadcast_message(message, exclude_user_id=message.from_user.id)
|
||||
|
||||
await ChatMessageService.save_message(
|
||||
session,
|
||||
user_id=user.id,
|
||||
telegram_message_id=message.message_id,
|
||||
message_type='voice',
|
||||
file_id=message.voice.file_id,
|
||||
forwarded_ids=forwarded_ids
|
||||
)
|
||||
|
||||
await message.answer(f"✅ Голосовое сообщение разослано: {success} получателей")
|
||||
|
||||
elif settings.mode == 'forward':
|
||||
if settings.forward_chat_id:
|
||||
success, channel_msg_id = await forward_to_channel(message, settings.forward_chat_id)
|
||||
|
||||
if success:
|
||||
await ChatMessageService.save_message(
|
||||
session,
|
||||
user_id=user.id,
|
||||
telegram_message_id=message.message_id,
|
||||
message_type='voice',
|
||||
file_id=message.voice.file_id,
|
||||
forwarded_ids={'channel': channel_msg_id}
|
||||
)
|
||||
await message.answer("✅ Голосовое сообщение переслано в канал")
|
||||
Reference in New Issue
Block a user