# adapter/telegram/handlers/chat.py from __future__ import annotations import asyncio import structlog from aiogram import F, Router from aiogram.filters import Command from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, Message from adapter.telegram import db from adapter.telegram.converter import ( format_outgoing, from_message, is_forum_message, resolve_forum_chat_id, ) from adapter.telegram.keyboards.chat import chats_list_keyboard from adapter.telegram.keyboards.confirm import confirm_keyboard from adapter.telegram.states import ChatState from core.handler import EventDispatcher from core.protocol import OutgoingMessage, OutgoingUI logger = structlog.get_logger(__name__) router = Router(name="chat") def _thread_id(message: Message) -> int | None: return getattr(message, "message_thread_id", None) def _callback_thread_id(callback: CallbackQuery) -> int | None: if callback.message is None: return None return getattr(callback.message, "message_thread_id", None) async def _send_reply( message: Message, text: str, *, reply_markup=None, thread_id: int | None = None, ) -> None: if thread_id is None: await message.answer(text, reply_markup=reply_markup) return await message.bot.send_message( message.chat.id, text, reply_markup=reply_markup, message_thread_id=thread_id, ) async def _send_outgoing( message: Message, chat_name: str, events: list, *, forum_mode: bool, thread_id: int | None = None, ) -> None: for event in events: if isinstance(event, OutgoingUI): action_id = ( event.buttons[0].payload.get("action_id", "unknown") if event.buttons else "unknown" ) kb = confirm_keyboard(action_id) await _send_reply( message, format_outgoing(chat_name, event, prefix=not forum_mode), reply_markup=kb, thread_id=thread_id, ) elif isinstance(event, OutgoingMessage): await _send_reply( message, format_outgoing(chat_name, event, prefix=not forum_mode), thread_id=thread_id, ) @router.message(ChatState.idle, (F.text | F.photo | F.document | F.voice) & ~F.text.startswith("/")) async def handle_message( message: Message, state: FSMContext, dispatcher: EventDispatcher, ) -> None: data = await state.get_data() tg_id = message.from_user.id tg_user = db.get_or_create_tg_user(tg_id, str(tg_id), message.from_user.full_name) platform_user_id = tg_user.get("platform_user_id", str(tg_id)) forum_mode = is_forum_message(message) thread_id = _thread_id(message) if forum_mode else None if forum_mode: chat_id = resolve_forum_chat_id(message, tg_id) if not chat_id: await _send_reply( message, "Эта форум-тема ещё не зарегистрирована. Выполните /new в этой теме.", thread_id=thread_id, ) return chat = db.get_chat_by_id(chat_id) chat_name = chat["name"] if chat else data.get("active_chat_name", "Чат") await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) else: chat_id = data.get("active_chat_id") chat_name = data.get("active_chat_name", "Чат") if not chat_id: await message.answer("Нет активного чата. Введите /start") return await state.set_state(ChatState.waiting_response) async def _typing_loop() -> None: while True: if thread_id is None: await message.bot.send_chat_action(message.chat.id, "typing") else: await message.bot.send_chat_action( message.chat.id, "typing", message_thread_id=thread_id, ) await asyncio.sleep(4) task = asyncio.create_task(_typing_loop()) events: list = [] try: incoming = from_message(message, chat_id) incoming.user_id = platform_user_id events = await dispatcher.dispatch(incoming) finally: task.cancel() try: await task except asyncio.CancelledError: pass await state.set_state(ChatState.idle) await _send_outgoing( message, chat_name, events, forum_mode=forum_mode, thread_id=thread_id, ) @router.message(Command("new")) async def cmd_new_chat(message: Message, state: FSMContext) -> None: tg_id = message.from_user.id args = message.text.split(maxsplit=1) name = args[1].strip() if len(args) > 1 else None thread_id = _thread_id(message) if thread_id is not None: chat = db.get_chat_by_thread(tg_id, thread_id) if chat: chat_id = chat["chat_id"] chat_name = name or chat["name"] if name and name != chat["name"]: db.rename_chat(chat_id, name) await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) await state.set_state(ChatState.idle) await _send_reply( message, f"✅ [{chat_name}] уже связан с этой темой.", thread_id=thread_id, ) return count = db.count_chats(tg_id) chat_name = name or f"Чат #{count + 1}" chat_id = db.create_chat(tg_id, chat_name) db.set_forum_thread(chat_id, thread_id) await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) await state.set_state(ChatState.idle) await _send_reply( message, f"✅ [{chat_name}] зарегистрирован в этой теме. Пиши!", thread_id=thread_id, ) return count = db.count_chats(tg_id) chat_name = name or f"Чат #{count + 1}" chat_id = db.create_chat(tg_id, chat_name) created_thread_id = None forum_group_id = db.get_forum_group(tg_id) if forum_group_id is not None: try: topic = await message.bot.create_forum_topic(chat_id=forum_group_id, name=chat_name) created_thread_id = ( getattr(topic, "message_thread_id", None) or getattr(topic, "thread_id", None) ) if created_thread_id is not None: db.set_forum_thread(chat_id, created_thread_id) except Exception as exc: # pragma: no cover - defensive fallback for Telegram API logger.warning( "Failed to create forum topic for new chat", tg_user_id=tg_id, chat_name=chat_name, error=str(exc), ) await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) await state.set_state(ChatState.idle) if created_thread_id is not None: await message.answer(f"✅ [{chat_name}] создан. Форум-тема тоже создана.") else: await message.answer(f"✅ [{chat_name}] создан. Пиши!") @router.message(Command("chats")) async def cmd_list_chats(message: Message, state: FSMContext) -> None: if is_forum_message(message): await _send_reply( message, "В forum-теме переключение между чатами отключено. " "Эта тема всегда привязана к одному чату. Используй /chats в личке с ботом.", thread_id=_thread_id(message), ) return tg_id = message.from_user.id chats = db.get_user_chats(tg_id) if not chats: await message.answer("Нет активных чатов. Введи /new чтобы создать.") return data = await state.get_data() active_id = data.get("active_chat_id") kb = chats_list_keyboard(chats, active_id) await message.answer("Твои чаты:", reply_markup=kb) @router.callback_query(F.data.startswith("switch:")) async def switch_chat(callback: CallbackQuery, state: FSMContext) -> None: if _callback_thread_id(callback) is not None: await callback.answer( "Переключение чатов доступно только в личке с ботом.", show_alert=True, ) return _, chat_id, chat_name = callback.data.split(":", 2) await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) await state.set_state(ChatState.idle) await callback.message.edit_text(f"✅ Переключился на [{chat_name}]") await callback.answer() @router.callback_query(F.data == "new_chat") async def cb_new_chat(callback: CallbackQuery, state: FSMContext) -> None: if _callback_thread_id(callback) is not None: await callback.answer( "Создание нового чата из списка доступно только в личке с ботом.", show_alert=True, ) return tg_id = callback.from_user.id count = db.count_chats(tg_id) chat_name = f"Чат #{count + 1}" chat_id = db.create_chat(tg_id, chat_name) await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) await state.set_state(ChatState.idle) await callback.message.edit_text(f"✅ [{chat_name}] создан. Пиши!") await callback.answer()