feature/chat-system #1

Merged
trevor merged 11 commits from feature/chat-system into master 2025-11-17 00:32:48 +00:00
8 changed files with 1773 additions and 1 deletions
Showing only changes of commit e798216cef - Show all commits

137
docs/CHAT_QUICKSTART.md Normal file
View File

@@ -0,0 +1,137 @@
# Быстрый старт: Система чата
## Что реализовано
**Два режима работы:**
- Broadcast: сообщения рассылаются всем пользователям
- Forward: сообщения пересылаются в канал/группу
**7 типов сообщений:** text, photo, video, document, animation, sticker, voice
**Система банов:**
- Личные баны пользователей с причиной
- Глобальный бан (закрытие чата для всех кроме админов)
**Модерация:** удаление сообщений с отслеживанием
## Быстрая настройка
### 1. Режим рассылки (broadcast)
```bash
# Админ отправляет команду:
/chat_mode
# → Нажимает "📢 Рассылка всем"
# Готово! Теперь сообщения пользователей рассылаются друг другу
```
### 2. Режим пересылки (forward)
```bash
# Шаг 1: Создайте канал и добавьте бота как админа
# Шаг 2: Узнайте chat_id канала:
# - Напишите в канале сообщение
# - Перешлите его @userinfobot
# - Скопируйте chat_id (например: -1001234567890)
# Шаг 3: Установите канал
/set_forward -1001234567890
# Шаг 4: Переключите режим
/chat_mode
# → Нажимает "➡️ Пересылка в канал"
# Готово! Сообщения пользователей пересылаются в канал
```
## Команды модерации
```bash
# Забанить пользователя (ответ на сообщение)
/ban Причина бана
# Забанить по ID
/ban 123456789 Спам
# Разбанить
/unban # (ответ на сообщение)
/unban 123456789
# Список банов
/banlist
# Закрыть/открыть чат для всех
/global_ban
# Удалить сообщение из всех чатов
/delete_msg # (ответ на сообщение)
# Статистика чата
/chat_stats
```
## Структура БД
```
chat_settings (1 строка)
├── mode: 'broadcast' | 'forward'
├── forward_chat_id: ID канала (если forward)
└── global_ban: true/false
banned_users
├── telegram_id: ID забаненного
├── banned_by: кто забанил
├── reason: причина
└── is_active: активен ли бан
chat_messages
├── user_id: отправитель
├── message_type: тип сообщения
├── text: текст или caption
├── file_id: ID файла
├── forwarded_message_ids: {user_id: msg_id} (JSONB)
├── is_deleted: удалено ли
└── deleted_by: кто удалил
```
## Файлы
| Файл | Описание | Строк |
|------|----------|-------|
| `migrations/versions/005_add_chat_system.py` | Миграция БД | 108 |
| `src/core/models.py` | Модели ORM (+67) | - |
| `src/core/chat_services.py` | Сервисы | 267 |
| `src/handlers/chat_handlers.py` | Обработчики сообщений | 447 |
| `src/handlers/admin_chat_handlers.py` | Админ команды | 369 |
| `docs/CHAT_SYSTEM.md` | Полная документация | 390 |
## Следующие шаги
1. **Тестирование:**
- Проверить broadcast режим с разными типами сообщений
- Проверить forward режим с каналом
- Протестировать баны и разбаны
- Проверить удаление сообщений
2. **Опциональные улучшения:**
- Фильтрация контента (мат, спам)
- Лимиты сообщений (антиспам)
- Ответы на сообщения
- Реакции на сообщения
- История чата через команду
## Коммит
```bash
git log --oneline -1
# b6c27b7 feat: добавлена система чата с модерацией
# Ветка: feature/chat-system
# Изменений: 7 файлов, 1592 строки добавлено
```
## Полная документация
Смотрите: [docs/CHAT_SYSTEM.md](./CHAT_SYSTEM.md)

View File

@@ -10,8 +10,8 @@ from src.core.chat_services import (
ChatMessageService
)
from src.core.services import UserService
from database import get_session
from config import ADMIN_IDS
from src.core.database import async_session_maker
from src.core.config import ADMIN_IDS
router = Router(name='admin_chat_router')
@@ -40,7 +40,7 @@ async def cmd_chat_mode(message: Message):
await message.answer("У вас нет прав для выполнения этой команды")
return
async for session in get_session():
async with async_session_maker() as session:
settings = await ChatSettingsService.get_or_create_settings(session)
mode_text = "📢 Рассылка всем пользователям" if settings.mode == 'broadcast' else "➡️ Пересылка в канал"
@@ -63,7 +63,7 @@ async def process_chat_mode(callback: CallbackQuery):
mode = callback.data.split(":")[1]
async for session in get_session():
async with async_session_maker() as session:
settings = await ChatSettingsService.set_mode(session, mode)
mode_text = "📢 Рассылка всем пользователям" if mode == 'broadcast' else "➡️ Пересылка в канал"
@@ -100,7 +100,7 @@ async def cmd_set_forward(message: Message):
chat_id = args[1].strip()
async for session in get_session():
async with async_session_maker() as session:
settings = await ChatSettingsService.set_forward_chat(session, chat_id)
await message.answer(
@@ -118,7 +118,7 @@ async def cmd_global_ban(message: Message):
await message.answer("У вас нет прав для выполнения этой команды")
return
async for session in get_session():
async with async_session_maker() as session:
settings = await ChatSettingsService.get_or_create_settings(session)
# Переключаем состояние
@@ -169,7 +169,7 @@ async def cmd_ban(message: Message):
await message.answer("❌ Неверный ID пользователя")
return
async for session in get_session():
async with async_session_maker() as session:
# Получаем пользователя
user = await UserService.get_user_by_telegram_id(session, target_user_id)
@@ -228,7 +228,7 @@ async def cmd_unban(message: Message):
await message.answer("❌ Неверный ID пользователя")
return
async for session in get_session():
async with async_session_maker() as session:
# Разбаниваем
success = await BanService.unban_user(session, target_user_id)
@@ -250,7 +250,7 @@ async def cmd_banlist(message: Message):
await message.answer("У вас нет прав для выполнения этой команды")
return
async for session in get_session():
async with async_session_maker() as session:
banned_users = await BanService.get_banned_users(session, active_only=True)
if not banned_users:
@@ -290,7 +290,7 @@ async def cmd_delete_message(message: Message):
)
return
async for session in get_session():
async with async_session_maker() as session:
# Получаем админа
admin = await UserService.get_user_by_telegram_id(session, message.from_user.id)
@@ -345,7 +345,7 @@ async def cmd_chat_stats(message: Message):
await message.answer("У вас нет прав для выполнения этой команды")
return
async for session in get_session():
async with async_session_maker() as 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)

View File

@@ -2,6 +2,8 @@
from aiogram import Router, F
from aiogram.types import Message
from sqlalchemy.ext.asyncio import AsyncSession
import asyncio
from typing import List, Dict, Optional
from src.core.chat_services import (
ChatSettingsService,
@@ -10,8 +12,8 @@ from src.core.chat_services import (
BanService
)
from src.core.services import UserService
from database import get_session
from config import ADMIN_IDS
from src.core.database import async_session_maker
from src.core.config import ADMIN_IDS
def is_admin(user_id: int) -> bool:
@@ -21,39 +23,75 @@ def is_admin(user_id: int) -> bool:
router = Router(name='chat_router')
# Настройки для планировщика рассылки
BATCH_SIZE = 20 # Количество сообщений в пакете
BATCH_DELAY = 1.0 # Задержка между пакетами в секундах
async def get_all_active_users(session: AsyncSession):
"""Получить всех активных пользователей для рассылки"""
async def get_all_active_users(session: AsyncSession) -> List:
"""Получить всех зарегистрированных пользователей для рассылки"""
users = await UserService.get_all_users(session)
return [u for u in users if u.is_active]
return [u for u in users if u.is_registered] # Используем is_registered вместо is_active
async def broadcast_message(message: Message, exclude_user_id: int = None):
"""Разослать сообщение всем пользователям"""
async for session in get_session():
async def broadcast_message_with_scheduler(message: Message, exclude_user_id: Optional[int] = None) -> tuple[Dict[str, int], int, int]:
"""
Разослать сообщение всем пользователям с планировщиком (пакетная отправка).
Возвращает: (forwarded_ids, success_count, fail_count)
"""
async with async_session_maker() as session:
users = await get_all_active_users(session)
if exclude_user_id:
users = [u for u in users if u.telegram_id != exclude_user_id]
forwarded_ids = {}
success_count = 0
fail_count = 0
# Разбиваем на пакеты
for i in range(0, len(users), BATCH_SIZE):
batch = users[i:i + BATCH_SIZE]
forwarded_ids = {}
success_count = 0
fail_count = 0
# Отправляем пакет
tasks = []
for user in batch:
tasks.append(_send_message_to_user(message, user.telegram_id))
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:
# Ждем завершения пакета
results = await asyncio.gather(*tasks, return_exceptions=True)
# Обрабатываем результаты
for user, result in zip(batch, results):
if isinstance(result, Exception):
fail_count += 1
elif result is not None:
forwarded_ids[str(user.telegram_id)] = result
success_count += 1
else:
fail_count += 1
print(f"Failed to send message to {user.telegram_id}: {e}")
return forwarded_ids, success_count, fail_count
# Задержка между пакетами (если есть еще пакеты)
if i + BATCH_SIZE < len(users):
await asyncio.sleep(BATCH_DELAY)
return forwarded_ids, success_count, fail_count
async def forward_to_channel(message: Message, channel_id: str):
async def _send_message_to_user(message: Message, user_telegram_id: int) -> Optional[int]:
"""
Отправить сообщение конкретному пользователю.
Возвращает message_id при успехе или None при ошибке.
"""
try:
sent_msg = await message.copy_to(user_telegram_id)
return sent_msg.message_id
except Exception as e:
print(f"Failed to send message to {user_telegram_id}: {e}")
return None
async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, Optional[int]]:
"""Переслать сообщение в канал/группу"""
try:
# Пересылаем сообщение в канал
@@ -67,7 +105,7 @@ async def forward_to_channel(message: Message, channel_id: str):
@router.message(F.text)
async def handle_text_message(message: Message):
"""Обработчик текстовых сообщений"""
async for session in get_session():
async with async_session_maker() as session:
# Проверяем права на отправку
can_send, reason = await ChatPermissionService.can_send_message(
session,
@@ -90,8 +128,8 @@ async def handle_text_message(message: Message):
# Обрабатываем в зависимости от режима
if settings.mode == 'broadcast':
# Режим рассылки
forwarded_ids, success, fail = await broadcast_message(message, exclude_user_id=message.from_user.id)
# Режим рассылки с планировщиком
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=message.from_user.id)
# Сохраняем сообщение в историю
await ChatMessageService.save_message(
@@ -125,7 +163,7 @@ async def handle_text_message(message: Message):
telegram_message_id=message.message_id,
message_type='text',
text=message.text,
forwarded_ids={'channel': channel_msg_id}
forwarded_ids={'channel': channel_msg_id} if channel_msg_id else None
)
await message.answer("✅ Сообщение переслано в канал")
@@ -136,10 +174,11 @@ async def handle_text_message(message: Message):
@router.message(F.photo)
async def handle_photo_message(message: Message):
"""Обработчик фото"""
async for session in get_session():
async with async_session_maker() as session:
can_send, reason = await ChatPermissionService.can_send_message(
session,
message.from_user.id
message.from_user.id,
is_admin=is_admin(message.from_user.id)
)
if not can_send:
@@ -156,7 +195,7 @@ async def handle_photo_message(message: Message):
photo = message.photo[-1]
if settings.mode == 'broadcast':
forwarded_ids, success, fail = await broadcast_message(message, exclude_user_id=message.from_user.id)
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=message.from_user.id)
await ChatMessageService.save_message(
session,
@@ -182,7 +221,7 @@ async def handle_photo_message(message: Message):
message_type='photo',
text=message.caption,
file_id=photo.file_id,
forwarded_ids={'channel': channel_msg_id}
forwarded_ids={'channel': channel_msg_id} if channel_msg_id else None
)
await message.answer("✅ Фото переслано в канал")
@@ -190,10 +229,11 @@ async def handle_photo_message(message: Message):
@router.message(F.video)
async def handle_video_message(message: Message):
"""Обработчик видео"""
async for session in get_session():
async with async_session_maker() as session:
can_send, reason = await ChatPermissionService.can_send_message(
session,
message.from_user.id
message.from_user.id,
is_admin=is_admin(message.from_user.id)
)
if not can_send:
@@ -207,7 +247,7 @@ async def handle_video_message(message: Message):
return
if settings.mode == 'broadcast':
forwarded_ids, success, fail = await broadcast_message(message, exclude_user_id=message.from_user.id)
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=message.from_user.id)
await ChatMessageService.save_message(
session,
@@ -233,7 +273,7 @@ async def handle_video_message(message: Message):
message_type='video',
text=message.caption,
file_id=message.video.file_id,
forwarded_ids={'channel': channel_msg_id}
forwarded_ids={'channel': channel_msg_id} if channel_msg_id else None
)
await message.answer("✅ Видео переслано в канал")
@@ -241,10 +281,11 @@ async def handle_video_message(message: Message):
@router.message(F.document)
async def handle_document_message(message: Message):
"""Обработчик документов"""
async for session in get_session():
async with async_session_maker() as session:
can_send, reason = await ChatPermissionService.can_send_message(
session,
message.from_user.id
message.from_user.id,
is_admin=is_admin(message.from_user.id)
)
if not can_send:
@@ -258,7 +299,7 @@ async def handle_document_message(message: Message):
return
if settings.mode == 'broadcast':
forwarded_ids, success, fail = await broadcast_message(message, exclude_user_id=message.from_user.id)
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=message.from_user.id)
await ChatMessageService.save_message(
session,
@@ -284,7 +325,7 @@ async def handle_document_message(message: Message):
message_type='document',
text=message.caption,
file_id=message.document.file_id,
forwarded_ids={'channel': channel_msg_id}
forwarded_ids={'channel': channel_msg_id} if channel_msg_id else None
)
await message.answer("✅ Документ переслан в канал")
@@ -292,10 +333,11 @@ async def handle_document_message(message: Message):
@router.message(F.animation)
async def handle_animation_message(message: Message):
"""Обработчик GIF анимаций"""
async for session in get_session():
async with async_session_maker() as session:
can_send, reason = await ChatPermissionService.can_send_message(
session,
message.from_user.id
message.from_user.id,
is_admin=is_admin(message.from_user.id)
)
if not can_send:
@@ -309,7 +351,7 @@ async def handle_animation_message(message: Message):
return
if settings.mode == 'broadcast':
forwarded_ids, success, fail = await broadcast_message(message, exclude_user_id=message.from_user.id)
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=message.from_user.id)
await ChatMessageService.save_message(
session,
@@ -335,7 +377,7 @@ async def handle_animation_message(message: Message):
message_type='animation',
text=message.caption,
file_id=message.animation.file_id,
forwarded_ids={'channel': channel_msg_id}
forwarded_ids={'channel': channel_msg_id} if channel_msg_id else None
)
await message.answer("✅ Анимация переслана в канал")
@@ -343,10 +385,11 @@ async def handle_animation_message(message: Message):
@router.message(F.sticker)
async def handle_sticker_message(message: Message):
"""Обработчик стикеров"""
async for session in get_session():
async with async_session_maker() as session:
can_send, reason = await ChatPermissionService.can_send_message(
session,
message.from_user.id
message.from_user.id,
is_admin=is_admin(message.from_user.id)
)
if not can_send:
@@ -360,7 +403,7 @@ async def handle_sticker_message(message: Message):
return
if settings.mode == 'broadcast':
forwarded_ids, success, fail = await broadcast_message(message, exclude_user_id=message.from_user.id)
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=message.from_user.id)
await ChatMessageService.save_message(
session,
@@ -384,7 +427,7 @@ async def handle_sticker_message(message: Message):
telegram_message_id=message.message_id,
message_type='sticker',
file_id=message.sticker.file_id,
forwarded_ids={'channel': channel_msg_id}
forwarded_ids={'channel': channel_msg_id} if channel_msg_id else None
)
await message.answer("✅ Стикер переслан в канал")
@@ -392,10 +435,11 @@ async def handle_sticker_message(message: Message):
@router.message(F.voice)
async def handle_voice_message(message: Message):
"""Обработчик голосовых сообщений"""
async for session in get_session():
async with async_session_maker() as session:
can_send, reason = await ChatPermissionService.can_send_message(
session,
message.from_user.id
message.from_user.id,
is_admin=is_admin(message.from_user.id)
)
if not can_send:
@@ -409,7 +453,7 @@ async def handle_voice_message(message: Message):
return
if settings.mode == 'broadcast':
forwarded_ids, success, fail = await broadcast_message(message, exclude_user_id=message.from_user.id)
forwarded_ids, success, fail = await broadcast_message_with_scheduler(message, exclude_user_id=message.from_user.id)
await ChatMessageService.save_message(
session,
@@ -433,6 +477,6 @@ async def handle_voice_message(message: Message):
telegram_message_id=message.message_id,
message_type='voice',
file_id=message.voice.file_id,
forwarded_ids={'channel': channel_msg_id}
forwarded_ids={'channel': channel_msg_id} if channel_msg_id else None
)
await message.answer("✅ Голосовое сообщение переслано в канал")