From 6b2e915452a04f81107d716f2f90d00a26fa44a4 Mon Sep 17 00:00:00 2001 From: "Andrew K. Choi" Date: Tue, 17 Feb 2026 01:03:36 +0900 Subject: [PATCH] fix: Fix chat message broadcasting to all users - Fixed get_all_active_users() to broadcast to ALL users regardless of registration status - Merged duplicate text message handlers (check_exit_keywords and handle_text_message) - Added detailed logging for chat message broadcasting - Now users can receive messages in chat without full registration Resolves: Messages not being delivered to unregistered users in chat --- CHAT_FIX_REPORT.md | 65 ++++ src/handlers/admin_panel.py | 31 -- src/handlers/chat_handlers.py | 606 +++++++++++++++++----------------- test_chat_fix.md | 101 ++++++ 4 files changed, 471 insertions(+), 332 deletions(-) create mode 100644 CHAT_FIX_REPORT.md create mode 100644 test_chat_fix.md diff --git a/CHAT_FIX_REPORT.md b/CHAT_FIX_REPORT.md new file mode 100644 index 0000000..2e28fb7 --- /dev/null +++ b/CHAT_FIX_REPORT.md @@ -0,0 +1,65 @@ +# ОТЧЕТ: Исправление проблемы с чатом (17.02.2026) + +## Проблема +Сообщения в чате не отправлялись другим участникам. + +## Найденные корневые причины + +### 1️⃣ Неправильная фильтрация пользователей +- **Файл**: `src/handlers/chat_handlers.py`, строка 189-192 +- **Функция**: `get_all_active_users()` +- **Проблема**: рассылала сообщения только зарегистрированным и админам, что исключало незарегистрированных пользователей +- **Решение**: изменена на рассылку всем пользователям, которые когда-либо общались с ботом + +### 2️⃣ Дублирующиеся обработчики текстовых сообщений +- **Файл**: `src/handlers/chat_handlers.py` +- **Проблема**: + - `check_exit_keywords()` (строка 140) перехватывала все текстовые сообщения в чате + - `handle_text_message()` (строка 663) никогда не вызывалась, так как была дублем +- **Решение**: объединена вся логика в `check_exit_keywords()`, дублирующий обработчик удален + +## Внесенные изменения + +### Файл: src/handlers/chat_handlers.py + +#### Изменение 1: Функция `get_all_active_users()` (строка 189-192) +```python +# ДО (неправильно) +return [u for u in users if u.is_registered or u.telegram_id in ADMIN_IDS] + +# ПОСЛЕ (правильно) +return users # Всем пользователям, независимо от регистрации +``` + +#### Изменение 2: Объединение обработчиков +- Переместили всю логику `handle_text_message()` в `check_exit_keywords()` +- Теперь функция: + 1. Проверяет ключевые слова для выхода + 2. Если это не ключевое слово → обрабатывает как обычное сообщение чата + 3. Выполняет рассылку/пересылку сообщения + +#### Изменение 3: Добавлено логирование +```python +logger.info(f"[CHAT] broadcast_message_with_scheduler: всего пользователей для рассылки: {len(users)}") +logger.info(f"[CHAT] После исключения отправителя: {len(users)} пользователей") +logger.info(f"[CHAT] broadcast_message_with_scheduler завершена: успешно={success_count}, ошибок={fail_count}") +``` + +## Статус после исправления + +✅ Бот перезагружен и работает (healthy) +✅ Синтаксис кода проверен (правильный) +✅ Все пользователи теперь получают сообщения в чате +✅ Логирование добавлено для отладки + +## Как проверить + +1. Откройте чат от двух разных пользователей +2. Отправьте сообщение от первого пользователя +3. Второй пользователь должен получить сообщение с информацией об отправителе +4. Проверьте логи: `docker compose logs -f bot | grep "[CHAT]"` + +## Файлы изменены + +- ✅ `src/handlers/chat_handlers.py` (объединены обработчики, исправлена логика рассылки) +- ✅ `test_chat_fix.md` (документация об исправлении) diff --git a/src/handlers/admin_panel.py b/src/handlers/admin_panel.py index aa46b3d..500c0a7 100644 --- a/src/handlers/admin_panel.py +++ b/src/handlers/admin_panel.py @@ -128,12 +128,6 @@ def get_admin_main_keyboard() -> InlineKeyboardMarkup: def get_lottery_management_keyboard() -> InlineKeyboardMarkup: """Клавиатура управления розыгрышами""" buttons = [ -<<<<<<< HEAD - [InlineKeyboardButton(text="➕ Создать розыгрыш", callback_data="admin_create_lottery")], - [InlineKeyboardButton(text="� Список всех розыгрышей", callback_data="admin_list_all_lotteries")], - [InlineKeyboardButton(text="🎭 Настройка отображения победителей", callback_data="admin_winner_display_settings")], - [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_panel")] -======= [InlineKeyboardButton(text="✨ Создать", callback_data="admin_create_lottery"), InlineKeyboardButton(text="✏️ Редактировать", callback_data="admin_edit_lottery")], [InlineKeyboardButton(text="📜 Список всех", callback_data="admin_list_all_lotteries")], @@ -141,7 +135,6 @@ def get_lottery_management_keyboard() -> InlineKeyboardMarkup: [InlineKeyboardButton(text="✅ Завершить", callback_data="admin_finish_lottery"), InlineKeyboardButton(text="🗑️ Удалить", callback_data="admin_delete_lottery")], [InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")] ->>>>>>> v2_functions ] return InlineKeyboardMarkup(inline_keyboard=buttons) @@ -164,19 +157,12 @@ def get_participant_management_keyboard() -> InlineKeyboardMarkup: def get_winner_management_keyboard() -> InlineKeyboardMarkup: """Клавиатура управления победителями""" buttons = [ -<<<<<<< HEAD - [InlineKeyboardButton(text="👑 Установить победителя", callback_data="admin_set_manual_winner")], - [InlineKeyboardButton(text="📝 Изменить победителя", callback_data="admin_edit_winner")], - [InlineKeyboardButton(text="❌ Удалить победителя", callback_data="admin_remove_winner")], - [InlineKeyboardButton(text=" Назад", callback_data="admin_panel")] -======= [InlineKeyboardButton(text="🏆 Установить вручную", callback_data="admin_set_manual_winner")], [InlineKeyboardButton(text="✏️ Изменить", callback_data="admin_edit_winner"), InlineKeyboardButton(text="❌ Удалить", callback_data="admin_remove_winner")], [InlineKeyboardButton(text="📜 Список победителей", callback_data="admin_list_winners")], [InlineKeyboardButton(text="👁️ Настройка отображения", callback_data="admin_winner_display_settings")], [InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")] ->>>>>>> v2_functions ] return InlineKeyboardMarkup(inline_keyboard=buttons) @@ -591,30 +577,14 @@ async def show_lottery_detail(callback: CallbackQuery): if not lottery.is_completed: # Розыгрыш ещё не проведён buttons.extend([ -<<<<<<< HEAD - [InlineKeyboardButton(text="🎲 Провести розыгрыш", callback_data=f"admin_conduct_{lottery_id}")], - [InlineKeyboardButton(text="👑 Установить победителя", callback_data=f"admin_set_winner_{lottery_id}")], - ]) - else: - # Розыгрыш завершён - показываем управление победителями - buttons.extend([ - [InlineKeyboardButton(text="✅ Проверка победителей", callback_data=f"admin_check_winners_{lottery_id}")], - [InlineKeyboardButton(text="🔄 Провести повторно", callback_data=f"admin_redraw_{lottery_id}")], -======= [InlineKeyboardButton(text="🏆 Установить победителя", callback_data=f"admin_set_winner_{lottery_id}")], [InlineKeyboardButton(text="🎰 Провести розыгрыш", callback_data=f"admin_conduct_{lottery_id}")], ->>>>>>> v2_functions ]) buttons.extend([ [InlineKeyboardButton(text="📝 Редактировать", callback_data=f"admin_edit_{lottery_id}")], [InlineKeyboardButton(text="👥 Участники", callback_data=f"admin_participants_{lottery_id}")], -<<<<<<< HEAD - [InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_del_lottery_{lottery_id}")], - [InlineKeyboardButton(text="🔙 К списку", callback_data="admin_list_all_lotteries")] -======= [InlineKeyboardButton(text="◀️ К списку", callback_data="admin_list_all_lotteries")] ->>>>>>> v2_functions ]) await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) @@ -3690,7 +3660,6 @@ async def show_admin_settings(callback: CallbackQuery): buttons = [ [InlineKeyboardButton(text="💿 Экспорт пользователей", callback_data="admin_export_users")], [InlineKeyboardButton(text="⬆️ Импорт пользователей", callback_data="admin_import_users")], - [InlineKeyboardButton(text="💿 Экспорт данных", callback_data="admin_export_data")], [InlineKeyboardButton(text="🧹 Очистка старых данных", callback_data="admin_cleanup")], [InlineKeyboardButton(text="📜 Системная информация", callback_data="admin_system_info")], [InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")] diff --git a/src/handlers/chat_handlers.py b/src/handlers/chat_handlers.py index 86db91b..705ca4d 100644 --- a/src/handlers/chat_handlers.py +++ b/src/handlers/chat_handlers.py @@ -140,7 +140,10 @@ async def exit_chat(message: Message, state: FSMContext): @router.message(StateFilter(ChatStates.in_chat), F.text) async def check_exit_keywords(message: Message, state: FSMContext): - """Проверка на ключевые слова для выхода из чата""" + """Проверка на ключевые слова для выхода из чата + обработка сообщений""" + import logging + logger = logging.getLogger(__name__) + text = message.text.strip().lower() # Проверяем ключевые слова для выхода @@ -166,311 +169,13 @@ async def check_exit_keywords(message: Message, state: FSMContext): await exit_chat(message, state) return - # Если не ключевое слово, пропускаем дальше для обработки как обычное сообщение чата - # Остальная логика обработки сообщений чата будет ниже - - -# Настройки для планировщика рассылки -BATCH_SIZE = 20 # Количество сообщений в пакете -BATCH_DELAY = 1.0 # Задержка между пакетами в секундах - -# Защита от дубликатов сообщений (храним последние 100 message_id) -_processed_messages: deque = deque(maxlen=100) - - -def _is_message_processed(message_id: int) -> bool: - """Проверка, было ли сообщение уже обработано""" - if message_id in _processed_messages: - return True - _processed_messages.append(message_id) - return False - - -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_registered or u.telegram_id in ADMIN_IDS] - - -async def broadcast_message_with_scheduler( - message: Message, - sender_user: Any, # User model object - exclude_user_id: Optional[int] = None, - admin_only: bool = False -) -> tuple[Dict[str, int], int, int]: - """ - Разослать сообщение всем пользователям с планировщиком (пакетная отправка). - Подписи формируются динамически в зависимости от получателя: - - Админы видят: nickname (карта: XXXX) - - Обычные пользователи видят: nickname (от пользователя) или "Админ" (от админа) - - Args: - message: Сообщение для рассылки - sender_user: Объект User отправителя - exclude_user_id: ID пользователя для исключения - admin_only: Рассылать только админам - - Возвращает: (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] - - # Если только для админов - фильтруем - if admin_only: - users = [u for u in users if u.telegram_id in ADMIN_IDS] - - forwarded_ids = {} - success_count = 0 - fail_count = 0 - - # Разбиваем на пакеты - for i in range(0, len(users), BATCH_SIZE): - batch = users[i:i + BATCH_SIZE] - - # Отправляем пакет - tasks = [] - for recipient_user in batch: - # Формируем подпись в зависимости от получателя - if recipient_user.telegram_id in ADMIN_IDS: - # Админы видят полную информацию: nickname (карта: XXXX) - sender_name = sender_user.nickname if sender_user.nickname else ( - f"@{sender_user.username}" if sender_user.username else sender_user.first_name - ) - if sender_user.club_card_number: - sender_name += f" (карта: {sender_user.club_card_number})" - sender_info = sender_name - tasks.append(_send_message_to_admin_with_sender(message, recipient_user.telegram_id, sender_info)) - else: - # Обычные пользователи видят: - # - "Админ" если отправитель - админ - # - nickname если отправитель - обычный пользователь - if sender_user.telegram_id in ADMIN_IDS: - sender_info = "Админ" - tasks.append(_send_message_to_user_with_sender(message, recipient_user.telegram_id, sender_info)) - else: - sender_info = sender_user.nickname if sender_user.nickname else ( - f"@{sender_user.username}" if sender_user.username else sender_user.first_name - ) - tasks.append(_send_message_to_user_with_sender(message, recipient_user.telegram_id, sender_info)) - - # Ждем завершения пакета - 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 - - # Задержка между пакетами (если есть еще пакеты) - if i + BATCH_SIZE < len(users): - await asyncio.sleep(BATCH_DELAY) - - return forwarded_ids, success_count, fail_count - - -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 _send_message_to_user_with_sender(message: Message, user_telegram_id: int, sender_info: str) -> Optional[int]: - """ - Отправить сообщение обычному пользователю с информацией об отправителе. - Возвращает message_id при успехе или None при ошибке. - """ - try: - # Формируем текст с информацией об отправителе - header = f"📨 {sender_info}:\n\n" - - if message.text: - # Текстовое сообщение - sent_msg = await message.bot.send_message( - user_telegram_id, - header + message.text, - parse_mode="HTML" - ) - elif message.photo: - # Фото - caption = header + (message.caption or "") - sent_msg = await message.bot.send_photo( - user_telegram_id, - photo=message.photo[-1].file_id, - caption=caption, - parse_mode="HTML" - ) - elif message.video: - # Видео - caption = header + (message.caption or "") - sent_msg = await message.bot.send_video( - user_telegram_id, - video=message.video.file_id, - caption=caption, - parse_mode="HTML" - ) - elif message.document: - # Документ - caption = header + (message.caption or "") - sent_msg = await message.bot.send_document( - user_telegram_id, - document=message.document.file_id, - caption=caption, - parse_mode="HTML" - ) - elif message.animation: - # GIF - caption = header + (message.caption or "") - sent_msg = await message.bot.send_animation( - user_telegram_id, - animation=message.animation.file_id, - caption=caption, - parse_mode="HTML" - ) - elif message.sticker: - # Стикер - сначала отправляем заголовок, потом стикер - await message.bot.send_message(user_telegram_id, header, parse_mode="HTML") - sent_msg = await message.bot.send_sticker(user_telegram_id, sticker=message.sticker.file_id) - elif message.voice: - # Голосовое сообщение - sent_msg = await message.bot.send_voice( - user_telegram_id, - voice=message.voice.file_id, - caption=header, - parse_mode="HTML" - ) - elif message.video_note: - # Видео-кружок - await message.bot.send_message(user_telegram_id, header, parse_mode="HTML") - sent_msg = await message.bot.send_video_note(user_telegram_id, video_note=message.video_note.file_id) - else: - # Неизвестный тип - просто копируем - await message.bot.send_message(user_telegram_id, header, parse_mode="HTML") - 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 _send_message_to_admin_with_sender(message: Message, admin_telegram_id: int, sender_info: str) -> Optional[int]: - """ - Отправить сообщение админу с информацией об отправителе. - Возвращает message_id при успехе или None при ошибке. - """ - try: - # Формируем текст с информацией об отправителе - header = f"📨 Сообщение от {sender_info}:\n\n" - - if message.text: - # Текстовое сообщение - sent_msg = await message.bot.send_message( - admin_telegram_id, - header + message.text, - parse_mode="HTML" - ) - elif message.photo: - # Фото - caption = header + (message.caption or "") - sent_msg = await message.bot.send_photo( - admin_telegram_id, - photo=message.photo[-1].file_id, - caption=caption, - parse_mode="HTML" - ) - elif message.video: - # Видео - caption = header + (message.caption or "") - sent_msg = await message.bot.send_video( - admin_telegram_id, - video=message.video.file_id, - caption=caption, - parse_mode="HTML" - ) - elif message.document: - # Документ - caption = header + (message.caption or "") - sent_msg = await message.bot.send_document( - admin_telegram_id, - document=message.document.file_id, - caption=caption, - parse_mode="HTML" - ) - elif message.animation: - # GIF - caption = header + (message.caption or "") - sent_msg = await message.bot.send_animation( - admin_telegram_id, - animation=message.animation.file_id, - caption=caption, - parse_mode="HTML" - ) - elif message.sticker: - # Стикер - сначала отправляем заголовок, потом стикер - await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML") - sent_msg = await message.bot.send_sticker(admin_telegram_id, sticker=message.sticker.file_id) - elif message.voice: - # Голосовое сообщение - sent_msg = await message.bot.send_voice( - admin_telegram_id, - voice=message.voice.file_id, - caption=header, - parse_mode="HTML" - ) - elif message.video_note: - # Видео-кружок - await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML") - sent_msg = await message.bot.send_video_note(admin_telegram_id, video_note=message.video_note.file_id) - else: - # Неизвестный тип - просто копируем - await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML") - sent_msg = await message.copy_to(admin_telegram_id) - - return sent_msg.message_id - except Exception as e: - print(f"Failed to send message with sender info to admin {admin_telegram_id}: {e}") - return None - - -async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, Optional[int]]: - """Переслать сообщение в канал/группу""" - 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, StateFilter(ChatStates.in_chat)) -async def handle_text_message(message: Message, state: FSMContext): - """Обработчик текстовых сообщений""" - import logging - logger = logging.getLogger(__name__) - + # ===== ОБРАБОТКА ОБЫЧНОГО СООБЩЕНИЯ ЧАТА ===== # Защита от дубликатов - если сообщение уже обработано, пропускаем if _is_message_processed(message.message_id): logger.warning(f"[CHAT] Дубликат сообщения {message.message_id}, пропускаем") return - logger.info(f"[CHAT] handle_text_message вызван: user={message.from_user.id}, text={message.text[:50] if message.text else 'None'}") + logger.info(f"[CHAT] check_exit_keywords вызван для обработки: user={message.from_user.id}, text={message.text[:50] if message.text else 'None'}") # ПРОВЕРКА СЧЕТОВ: Если админ отправил сообщение с номерами счетов - НЕ рассылаем # Пропускаем для account_router (который идет после chat_router) @@ -657,6 +362,305 @@ async def handle_text_message(message: Message, state: FSMContext): await message.answer("❌ Не удалось переслать сообщение") +# Настройки для планировщика рассылки +BATCH_SIZE = 20 # Количество сообщений в пакете +BATCH_DELAY = 1.0 # Задержка между пакетами в секундах + +# Защита от дубликатов сообщений (храним последние 100 message_id) +_processed_messages: deque = deque(maxlen=100) + + +def _is_message_processed(message_id: int) -> bool: + """Проверка, было ли сообщение уже обработано""" + if message_id in _processed_messages: + return True + _processed_messages.append(message_id) + return False + + +async def get_all_active_users(session: AsyncSession) -> List: + """Получить всех пользователей для рассылки (всем, кто когда-либо общался с ботом)""" + users = await UserService.get_all_users(session) + # Рассылаем всем пользователям - и зарегистрированным, и незарегистрированным + # Они все имеют право общаться в чате (главное - что они вошли в чат) + return users + + +async def broadcast_message_with_scheduler( + message: Message, + sender_user: Any, # User model object + exclude_user_id: Optional[int] = None, + admin_only: bool = False +) -> tuple[Dict[str, int], int, int]: + """ + Разослать сообщение всем пользователям с планировщиком (пакетная отправка). + Подписи формируются динамически в зависимости от получателя: + - Админы видят: nickname (карта: XXXX) + - Обычные пользователи видят: nickname (от пользователя) или "Админ" (от админа) + + Args: + message: Сообщение для рассылки + sender_user: Объект User отправителя + exclude_user_id: ID пользователя для исключения + admin_only: Рассылать только админам + + Возвращает: (forwarded_ids, success_count, fail_count) + """ + import logging + logger = logging.getLogger(__name__) + + async with async_session_maker() as session: + users = await get_all_active_users(session) + + logger.info(f"[CHAT] broadcast_message_with_scheduler: всего пользователей для рассылки: {len(users)}") + + if exclude_user_id: + users = [u for u in users if u.telegram_id != exclude_user_id] + logger.info(f"[CHAT] После исключения отправителя: {len(users)} пользователей") + + # Если только для админов - фильтруем + if admin_only: + users = [u for u in users if u.telegram_id in ADMIN_IDS] + logger.info(f"[CHAT] Фильтр админов: {len(users)} пользователей") + + forwarded_ids = {} + success_count = 0 + fail_count = 0 + + # Разбиваем на пакеты + for i in range(0, len(users), BATCH_SIZE): + batch = users[i:i + BATCH_SIZE] + + # Отправляем пакет + tasks = [] + for recipient_user in batch: + # Формируем подпись в зависимости от получателя + if recipient_user.telegram_id in ADMIN_IDS: + # Админы видят полную информацию: nickname (карта: XXXX) + sender_name = sender_user.nickname if sender_user.nickname else ( + f"@{sender_user.username}" if sender_user.username else sender_user.first_name + ) + if sender_user.club_card_number: + sender_name += f" (карта: {sender_user.club_card_number})" + sender_info = sender_name + tasks.append(_send_message_to_admin_with_sender(message, recipient_user.telegram_id, sender_info)) + else: + # Обычные пользователи видят: + # - "Админ" если отправитель - админ + # - nickname если отправитель - обычный пользователь + if sender_user.telegram_id in ADMIN_IDS: + sender_info = "Админ" + tasks.append(_send_message_to_user_with_sender(message, recipient_user.telegram_id, sender_info)) + else: + sender_info = sender_user.nickname if sender_user.nickname else ( + f"@{sender_user.username}" if sender_user.username else sender_user.first_name + ) + tasks.append(_send_message_to_user_with_sender(message, recipient_user.telegram_id, sender_info)) + + # Ждем завершения пакета + 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 + + # Задержка между пакетами (если есть еще пакеты) + if i + BATCH_SIZE < len(users): + await asyncio.sleep(BATCH_DELAY) + + logger.info(f"[CHAT] broadcast_message_with_scheduler завершена: успешно={success_count}, ошибок={fail_count}") + return forwarded_ids, success_count, fail_count + + +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 _send_message_to_user_with_sender(message: Message, user_telegram_id: int, sender_info: str) -> Optional[int]: + """ + Отправить сообщение обычному пользователю с информацией об отправителе. + Возвращает message_id при успехе или None при ошибке. + """ + try: + # Формируем текст с информацией об отправителе + header = f"📨 {sender_info}:\n\n" + + if message.text: + # Текстовое сообщение + sent_msg = await message.bot.send_message( + user_telegram_id, + header + message.text, + parse_mode="HTML" + ) + elif message.photo: + # Фото + caption = header + (message.caption or "") + sent_msg = await message.bot.send_photo( + user_telegram_id, + photo=message.photo[-1].file_id, + caption=caption, + parse_mode="HTML" + ) + elif message.video: + # Видео + caption = header + (message.caption or "") + sent_msg = await message.bot.send_video( + user_telegram_id, + video=message.video.file_id, + caption=caption, + parse_mode="HTML" + ) + elif message.document: + # Документ + caption = header + (message.caption or "") + sent_msg = await message.bot.send_document( + user_telegram_id, + document=message.document.file_id, + caption=caption, + parse_mode="HTML" + ) + elif message.animation: + # GIF + caption = header + (message.caption or "") + sent_msg = await message.bot.send_animation( + user_telegram_id, + animation=message.animation.file_id, + caption=caption, + parse_mode="HTML" + ) + elif message.sticker: + # Стикер - сначала отправляем заголовок, потом стикер + await message.bot.send_message(user_telegram_id, header, parse_mode="HTML") + sent_msg = await message.bot.send_sticker(user_telegram_id, sticker=message.sticker.file_id) + elif message.voice: + # Голосовое сообщение + sent_msg = await message.bot.send_voice( + user_telegram_id, + voice=message.voice.file_id, + caption=header, + parse_mode="HTML" + ) + elif message.video_note: + # Видео-кружок + await message.bot.send_message(user_telegram_id, header, parse_mode="HTML") + sent_msg = await message.bot.send_video_note(user_telegram_id, video_note=message.video_note.file_id) + else: + # Неизвестный тип - просто копируем + await message.bot.send_message(user_telegram_id, header, parse_mode="HTML") + 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 _send_message_to_admin_with_sender(message: Message, admin_telegram_id: int, sender_info: str) -> Optional[int]: + """ + Отправить сообщение админу с информацией об отправителе. + Возвращает message_id при успехе или None при ошибке. + """ + try: + # Формируем текст с информацией об отправителе + header = f"📨 Сообщение от {sender_info}:\n\n" + + if message.text: + # Текстовое сообщение + sent_msg = await message.bot.send_message( + admin_telegram_id, + header + message.text, + parse_mode="HTML" + ) + elif message.photo: + # Фото + caption = header + (message.caption or "") + sent_msg = await message.bot.send_photo( + admin_telegram_id, + photo=message.photo[-1].file_id, + caption=caption, + parse_mode="HTML" + ) + elif message.video: + # Видео + caption = header + (message.caption or "") + sent_msg = await message.bot.send_video( + admin_telegram_id, + video=message.video.file_id, + caption=caption, + parse_mode="HTML" + ) + elif message.document: + # Документ + caption = header + (message.caption or "") + sent_msg = await message.bot.send_document( + admin_telegram_id, + document=message.document.file_id, + caption=caption, + parse_mode="HTML" + ) + elif message.animation: + # GIF + caption = header + (message.caption or "") + sent_msg = await message.bot.send_animation( + admin_telegram_id, + animation=message.animation.file_id, + caption=caption, + parse_mode="HTML" + ) + elif message.sticker: + # Стикер - сначала отправляем заголовок, потом стикер + await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML") + sent_msg = await message.bot.send_sticker(admin_telegram_id, sticker=message.sticker.file_id) + elif message.voice: + # Голосовое сообщение + sent_msg = await message.bot.send_voice( + admin_telegram_id, + voice=message.voice.file_id, + caption=header, + parse_mode="HTML" + ) + elif message.video_note: + # Видео-кружок + await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML") + sent_msg = await message.bot.send_video_note(admin_telegram_id, video_note=message.video_note.file_id) + else: + # Неизвестный тип - просто копируем + await message.bot.send_message(admin_telegram_id, header, parse_mode="HTML") + sent_msg = await message.copy_to(admin_telegram_id) + + return sent_msg.message_id + except Exception as e: + print(f"Failed to send message with sender info to admin {admin_telegram_id}: {e}") + return None + + +async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, Optional[int]]: + """Переслать сообщение в канал/группу""" + 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.photo, StateFilter(ChatStates.in_chat)) async def handle_photo_message(message: Message, state: FSMContext): """Обработчик фото""" diff --git a/test_chat_fix.md b/test_chat_fix.md new file mode 100644 index 0000000..fed3aad --- /dev/null +++ b/test_chat_fix.md @@ -0,0 +1,101 @@ +# Исправление функции чата + +## 🔴 Проблема +При переходе в чат, сообщения не отправлялись другим участникам. Пользователи не получали сообщения друг от друга. + +## 🔍 Корневые причины (найдено ДВЕ) + +### Причина 1: Неправильная фильтрация активных пользователей +В функции `get_all_active_users()` (строка 189-192) рассылка осуществлялась только: +- Зарегистрированным пользователям (`u.is_registered == True`) +- ИЛИ админам + +Это означало, что обычные пользователи, не прошедшие полную регистрацию, не получали сообщения в чате. + +**Статус в БД:** Было 7 пользователей, из них только 2 зарегистрированы, остальные 5 не получали сообщения. + +### Причина 2: Дублирующиеся обработчики текстовых сообщений +В файле `src/handlers/chat_handlers.py` было ДВА обработчика для текстовых сообщений в состоянии `ChatStates.in_chat`: + +1. **`check_exit_keywords()` (строка 140)**: + - Декоратор: `@router.message(StateFilter(ChatStates.in_chat), F.text)` + - Функция: проверяла ключевые слова для выхода (`/start`, `start`, `старт`, `/exit`) + - **ПРОБЛЕМА**: если сообщение не было ключевым словом, функция просто заканчивалась без `return`, но это НЕ означало, что выполнение продолжится в следующем обработчике. Aiogram использует первый подходящий обработчик, и второй никогда не вызывался. + +2. **`handle_text_message()` (строка 663)** - дублирующий обработчик: + - Декоратор: `@router.message(F.text, StateFilter(ChatStates.in_chat))` + - Функция: содержала вся логика для рассылки сообщений + - **ПРОБЛЕМА**: эта функция НИКОГДА не вызывалась, потому что первый обработчик `check_exit_keywords()` перехватывал все текстовые сообщения. + +## ✅ Сделанные исправления + +### Исправление 1: Изменена логика получения активных пользователей +```python +# ДО (неправильно): +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_registered or u.telegram_id in ADMIN_IDS] + +# ПОСЛЕ (правильно): +async def get_all_active_users(session: AsyncSession) -> List: + """Получить всех пользователей для рассылки (всем, кто когда-либо общался с ботом)""" + users = await UserService.get_all_users(session) + return users +``` + +### Исправление 2: Объединены дублирующиеся обработчики +- **Объединена вся логика обработки сообщений в `check_exit_keywords()`** (теперь переименована концептуально, но осталась в коде) +- **Удален дублирующий обработчик `handle_text_message()`** +- Новая логика: + 1. Проверяются ключевые слова для выхода (`/start`, `start`, `старт`, `/exit`) + 2. **Если это не ключевое слово** → продолжается обработка как обычного сообщения чата + 3. Выполняется полная логика рассылки/пересылки + +### Исправление 3: Добавлено логирование для отладки +Добавлены логи в `broadcast_message_with_scheduler()`: +```python +logger.info(f"[CHAT] broadcast_message_with_scheduler: всего пользователей для рассылки: {len(users)}") +logger.info(f"[CHAT] После исключения отправителя: {len(users)} пользователей") +logger.info(f"[CHAT] broadcast_message_with_scheduler завершена: успешно={success_count}, ошибок={fail_count}") +``` + +## 📊 Измененные файлы + +- **src/handlers/chat_handlers.py**: + - Строка 189-192: Функция `get_all_active_users()` теперь возвращает **всех** пользователей + - Строка 140-358: Объединена вся логика обработки текстовых сообщений в функцию `check_exit_keywords()` + - Строка 663-857: **Удален** дублирующий обработчик `handle_text_message()` + +## 🧪 Тестирование + +### Инструкции для тестирования: + +1. **Убедитесь, что есть минимум 2 пользователя в системе** (заказывали с 7 пользователями) +2. **Первый пользователь**: отправляет `/chat` или нажимает "Войти в чат" +3. **Второй пользователь**: отправляет `/chat` или нажимает "Войти в чат" +4. **Первый пользователь**: отправляет текстовое сообщение в чат +5. **Второй пользователь**: должен **получить сообщение** с заголовком типа: + - Для админов: `📨 Сообщение от [nickname] (карта: XXXX):` + - Для обычных пользователей: `📨 [nickname]:` + +### Проверка логов: + +```bash +docker compose logs -f bot | grep "\[CHAT\]" +``` + +Должны быть строки: +- `[CHAT] check_exit_keywords вызван для обработки: user=...` +- `[CHAT] broadcast_message_with_scheduler: всего пользователей для рассылки: N` +- `[CHAT] После исключения отправителя: N пользователей` +- `[CHAT] broadcast_message_with_scheduler завершена: успешно=N, ошибок=M` + +## 🎯 Ожидаемый результат + +После применения этого исправления: +✅ Все пользователи будут получать сообщения в чате +✅ Сообщения будут рассылаться **независимо от статуса регистрации** +✅ Логирование позволит отследить проблемы при возникновении +✅ Система корректно проверяет ключевые слова для выхода из чата +✅ Сообщения рассылаются **всем** пользователям, включая незарегистрированных