fix: исправлены импорты и добавлен планировщик рассылки
Some checks reported errors
continuous-integration/drone/push Build encountered an error

- Исправлены импорты: database → src.core.database, config → src.core.config
- Заменены async for get_session() на async with async_session_maker()
- Добавлен планировщик для пакетной рассылки сообщений (BATCH_SIZE=20, BATCH_DELAY=1.0s)
- Исправлено использование is_registered вместо is_active для фильтрации пользователей
- Реализована защита от блокировки Telegram при массовой рассылке

Изменения:
- src/handlers/chat_handlers.py: добавлен broadcast_message_with_scheduler
- src/handlers/admin_chat_handlers.py: исправлены импорты и использование сессий
This commit is contained in:
2025-11-16 14:35:33 +09:00
parent b6c27b7b70
commit e798216cef
3 changed files with 244 additions and 63 deletions

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

View File

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