From e798216ceff0c7e6a83d0757f57f5d1f97a97b66 Mon Sep 17 00:00:00 2001 From: "Andrew K. Choi" Date: Sun, 16 Nov 2025 14:35:33 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82?= =?UTF-8?q?=D1=8B=20=D0=B8=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D1=89?= =?UTF-8?q?=D0=B8=D0=BA=20=D1=80=D0=B0=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Исправлены импорты: 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: исправлены импорты и использование сессий --- docs/CHAT_QUICKSTART.md | 137 +++++++++++++++++++++++++ src/handlers/admin_chat_handlers.py | 22 ++--- src/handlers/chat_handlers.py | 148 ++++++++++++++++++---------- 3 files changed, 244 insertions(+), 63 deletions(-) create mode 100644 docs/CHAT_QUICKSTART.md diff --git a/docs/CHAT_QUICKSTART.md b/docs/CHAT_QUICKSTART.md new file mode 100644 index 0000000..cbb9bd3 --- /dev/null +++ b/docs/CHAT_QUICKSTART.md @@ -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) diff --git a/src/handlers/admin_chat_handlers.py b/src/handlers/admin_chat_handlers.py index 0570fda..65d96f0 100644 --- a/src/handlers/admin_chat_handlers.py +++ b/src/handlers/admin_chat_handlers.py @@ -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) diff --git a/src/handlers/chat_handlers.py b/src/handlers/chat_handlers.py index 4effce1..b8cc5e8 100644 --- a/src/handlers/chat_handlers.py +++ b/src/handlers/chat_handlers.py @@ -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("✅ Голосовое сообщение переслано в канал")