surfaces/adapter/telegram/handlers/chat.py

282 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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()