282 lines
9.5 KiB
Python
282 lines
9.5 KiB
Python
# 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()
|