Improve Telegram forum onboarding and topic safety

This commit is contained in:
Mikhail Putilovskij 2026-04-01 01:49:45 +03:00
parent 2b56b98697
commit a1b7a14138
13 changed files with 1101 additions and 376 deletions

View file

@ -3,31 +3,87 @@ from __future__ import annotations
import asyncio
import structlog
from aiogram import F, Router
from aiogram.filters import Command, CommandObject
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
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
from adapter.telegram.keyboards.confirm import confirm_keyboard
logger = structlog.get_logger(__name__)
router = Router(name="chat")
async def _send_outgoing(message: Message, chat_name: str, events: list) -> None:
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):
from adapter.telegram.keyboards.confirm import confirm_keyboard
action_id = event.buttons[0].payload.get("action_id", "unknown") if event.buttons else "unknown"
action_id = (
event.buttons[0].payload.get("action_id", "unknown")
if event.buttons
else "unknown"
)
kb = confirm_keyboard(action_id)
await message.answer(format_outgoing(chat_name, event), reply_markup=kb)
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 message.answer(format_outgoing(chat_name, event))
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("/"))
@ -37,8 +93,28 @@ async def handle_message(
dispatcher: EventDispatcher,
) -> None:
data = await state.get_data()
chat_id = data.get("active_chat_id")
chat_name = data.get("active_chat_name", "Чат")
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")
@ -46,18 +122,21 @@ async def handle_message(
await state.set_state(ChatState.waiting_response)
# Typing indicator loop
async def _typing_loop():
async def _typing_loop() -> None:
while True:
await message.bot.send_chat_action(message.chat.id, "typing")
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:
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))
incoming = from_message(message, chat_id)
incoming.user_id = platform_user_id
events = await dispatcher.dispatch(incoming)
@ -69,7 +148,13 @@ async def handle_message(
pass
await state.set_state(ChatState.idle)
await _send_outgoing(message, chat_name, events)
await _send_outgoing(
message,
chat_name,
events,
forum_mode=forum_mode,
thread_id=thread_id,
)
@router.message(Command("new"))
@ -77,18 +162,79 @@ 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)
await message.answer(f"✅ [{chat_name}] создан. Пиши!")
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:
@ -103,6 +249,13 @@ async def cmd_list_chats(message: Message, state: FSMContext) -> None:
@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)
@ -112,6 +265,13 @@ async def switch_chat(callback: CallbackQuery, state: FSMContext) -> None:
@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}"