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

@ -10,7 +10,7 @@ from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import BotCommand
from adapter.telegram import db
from adapter.telegram.handlers import auth, chat, confirm, settings
from adapter.telegram.handlers import auth, chat, confirm, forum, settings
from core.auth import AuthManager
from core.chat import ChatManager
from core.handler import EventDispatcher
@ -54,7 +54,7 @@ def build_event_dispatcher(platform: MockPlatformClient) -> EventDispatcher:
)
# Register core handlers
from core.protocol import IncomingCommand, IncomingMessage, IncomingCallback
from core.protocol import IncomingCallback, IncomingCommand, IncomingMessage
ed.register(IncomingCommand, "start", handle_start)
ed.register(IncomingCommand, "settings", handle_settings)
ed.register(IncomingCommand, "settings_skills", handle_settings_skills)
@ -89,6 +89,7 @@ async def main() -> None:
# Include routers
dp.include_router(auth.router)
dp.include_router(forum.router)
dp.include_router(chat.router)
dp.include_router(settings.router)
dp.include_router(confirm.router)

View file

@ -3,6 +3,7 @@ from __future__ import annotations
from aiogram.types import Message
from adapter.telegram import db
from core.protocol import Attachment, IncomingMessage, OutgoingEvent, OutgoingMessage, OutgoingUI
@ -16,6 +17,21 @@ def from_message(message: Message, chat_id: str) -> IncomingMessage:
)
def is_forum_message(message: Message) -> bool:
return getattr(message, "message_thread_id", None) is not None
def resolve_forum_chat_id(message: Message, tg_user_id: int) -> str | None:
thread_id = getattr(message, "message_thread_id", None)
if thread_id is None:
return None
chat = db.get_chat_by_thread(tg_user_id, thread_id)
if not chat:
return None
return chat["chat_id"]
def _extract_attachments(message: Message) -> list[Attachment]:
attachments: list[Attachment] = []
if message.photo:
@ -41,10 +57,10 @@ def _extract_attachments(message: Message) -> list[Attachment]:
return attachments
def format_outgoing(chat_name: str, event: OutgoingEvent) -> str:
prefix = f"[{chat_name}] "
def format_outgoing(chat_name: str, event: OutgoingEvent, *, prefix: bool = True) -> str:
rendered_prefix = f"[{chat_name}] " if prefix else ""
if isinstance(event, OutgoingMessage):
return prefix + event.text
return rendered_prefix + event.text
if isinstance(event, OutgoingUI):
return prefix + event.text
return prefix + str(event)
return rendered_prefix + event.text
return rendered_prefix + str(event)

View file

@ -27,18 +27,29 @@ def init_db() -> None:
tg_user_id INTEGER PRIMARY KEY,
platform_user_id TEXT NOT NULL,
display_name TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
forum_group_id INTEGER
);
CREATE TABLE IF NOT EXISTS chats (
chat_id TEXT PRIMARY KEY,
tg_user_id INTEGER NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
archived_at TIMESTAMP,
chat_id TEXT PRIMARY KEY,
tg_user_id INTEGER NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
archived_at TIMESTAMP,
forum_thread_id INTEGER,
FOREIGN KEY(tg_user_id) REFERENCES tg_users(tg_user_id)
);
""")
# Миграция для существующих БД
try:
con.execute("ALTER TABLE tg_users ADD COLUMN forum_group_id INTEGER")
except Exception:
pass
try:
con.execute("ALTER TABLE chats ADD COLUMN forum_thread_id INTEGER")
except Exception:
pass
def get_or_create_tg_user(
@ -119,3 +130,38 @@ def archive_chat(chat_id: str) -> None:
"UPDATE chats SET archived_at = CURRENT_TIMESTAMP WHERE chat_id = ?",
(chat_id,),
)
def set_forum_group(tg_user_id: int, group_id: int) -> None:
with _conn() as con:
con.execute(
"UPDATE tg_users SET forum_group_id = ? WHERE tg_user_id = ?",
(group_id, tg_user_id),
)
def get_forum_group(tg_user_id: int) -> int | None:
with _conn() as con:
row = con.execute(
"SELECT forum_group_id FROM tg_users WHERE tg_user_id = ?",
(tg_user_id,),
).fetchone()
return row["forum_group_id"] if row else None
def set_forum_thread(chat_id: str, thread_id: int) -> None:
with _conn() as con:
con.execute(
"UPDATE chats SET forum_thread_id = ? WHERE chat_id = ?",
(thread_id, chat_id),
)
def get_chat_by_thread(tg_user_id: int, thread_id: int) -> dict | None:
with _conn() as con:
row = con.execute(
"SELECT * FROM chats WHERE tg_user_id = ? AND forum_thread_id = ? "
"AND archived_at IS NULL",
(tg_user_id, thread_id),
).fetchone()
return dict(row) if row else None

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}"

View file

@ -5,12 +5,35 @@ from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery
from adapter.telegram import db
from adapter.telegram.converter import format_outgoing, is_forum_message, resolve_forum_chat_id
from core.handler import EventDispatcher
from core.protocol import IncomingCallback
from core.protocol import IncomingCallback, OutgoingMessage, OutgoingUI
router = Router(name="confirm")
async def _send_reply(
callback: CallbackQuery,
text: str,
*,
thread_id: int | None = None,
) -> None:
if callback.message is None:
await callback.answer()
return
if thread_id is None:
await callback.message.answer(text)
return
await callback.message.bot.send_message(
callback.message.chat.id,
text,
message_thread_id=thread_id,
)
@router.callback_query(F.data.startswith("confirm:"))
async def handle_confirm(
callback: CallbackQuery,
@ -23,12 +46,21 @@ async def handle_confirm(
data = await state.get_data()
chat_id = data.get("active_chat_id", "")
chat_name = data.get("active_chat_name", "Чат")
thread_id = getattr(callback.message, "message_thread_id", None)
forum_mode = callback.message is not None and is_forum_message(callback.message)
from adapter.telegram import db as tgdb
tg_id = callback.from_user.id
tg_user = tgdb.get_or_create_tg_user(tg_id, str(tg_id), callback.from_user.full_name)
tg_user = db.get_or_create_tg_user(tg_id, str(tg_id), callback.from_user.full_name)
platform_user_id = tg_user.get("platform_user_id", str(tg_id))
if forum_mode and callback.message is not None:
resolved_chat_id = resolve_forum_chat_id(callback.message, tg_id)
if resolved_chat_id:
chat_id = resolved_chat_id
chat = db.get_chat_by_id(chat_id)
if chat:
chat_name = chat["name"]
incoming = IncomingCallback(
user_id=platform_user_id,
platform="telegram",
@ -38,12 +70,12 @@ async def handle_confirm(
)
events = await dispatcher.dispatch(incoming)
await callback.message.edit_reply_markup(reply_markup=None)
if callback.message is not None:
await callback.message.edit_reply_markup(reply_markup=None)
for event in events:
from core.protocol import OutgoingMessage, OutgoingUI
from adapter.telegram.converter import format_outgoing
if isinstance(event, (OutgoingMessage, OutgoingUI)):
await callback.message.answer(format_outgoing(chat_name, event))
rendered = format_outgoing(chat_name, event, prefix=not forum_mode)
await _send_reply(callback, rendered, thread_id=thread_id if forum_mode else None)
await callback.answer()

View file

@ -0,0 +1,212 @@
from __future__ import annotations
import structlog
from aiogram import F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import Chat, Message, ReplyKeyboardRemove
from adapter.telegram import db
from adapter.telegram.keyboards.forum import forum_group_request_keyboard
from adapter.telegram.states import ChatState, ForumSetupState
logger = structlog.get_logger(__name__)
router = Router(name="forum")
def _thread_id_from_topic(topic: object) -> int | None:
thread_id = getattr(topic, "message_thread_id", None)
if thread_id is not None:
return thread_id
return getattr(topic, "thread_id", None)
def _resolve_forwarded_chat(message: Message) -> Chat | None:
forwarded_chat = getattr(message, "forward_from_chat", None)
if forwarded_chat is not None:
return forwarded_chat
forward_origin = getattr(message, "forward_origin", None)
if forward_origin is None:
return None
sender_chat = getattr(forward_origin, "sender_chat", None)
if sender_chat is not None:
return sender_chat
return getattr(forward_origin, "chat", None)
def _forward_debug_payload(message: Message) -> dict[str, object]:
forward_origin = getattr(message, "forward_origin", None)
forwarded_chat = _resolve_forwarded_chat(message)
return {
"has_forward_from_chat": getattr(message, "forward_from_chat", None) is not None,
"has_forward_origin": forward_origin is not None,
"forward_origin_type": getattr(forward_origin, "type", None),
"forwarded_chat_id": getattr(forwarded_chat, "id", None),
"forwarded_chat_type": getattr(forwarded_chat, "type", None),
"forwarded_chat_is_forum": getattr(forwarded_chat, "is_forum", None),
}
async def _send_message(
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 _complete_group_link(message: Message, state: FSMContext, forwarded_chat: Chat) -> None:
bot_user = await message.bot.get_me()
member = await message.bot.get_chat_member(forwarded_chat.id, bot_user.id)
can_manage_topics = getattr(member, "can_manage_topics", False)
is_admin = member.status in ("administrator", "creator")
if not is_admin or (member.status == "administrator" and not can_manage_topics):
logger.warning(
"Forum onboarding failed: bot lacks forum admin rights",
tg_user_id=message.from_user.id,
forum_group_id=forwarded_chat.id,
member_status=member.status,
can_manage_topics=can_manage_topics,
)
await message.answer(
"Я не вижу прав на управление темами. "
"Добавь меня администратором с правом `can_manage_topics` и попробуй снова.",
reply_markup=ReplyKeyboardRemove(),
)
return
tg_user_id = message.from_user.id
db.set_forum_group(tg_user_id, forwarded_chat.id)
logger.info(
"Forum group linked",
tg_user_id=tg_user_id,
forum_group_id=forwarded_chat.id,
forum_group_title=getattr(forwarded_chat, "title", None),
)
created_topics = 0
for chat in db.get_user_chats(tg_user_id):
if chat.get("forum_thread_id") is not None:
continue
topic = await message.bot.create_forum_topic(
chat_id=forwarded_chat.id,
name=chat["name"],
)
thread_id = _thread_id_from_topic(topic)
if thread_id is None:
logger.warning("Forum topic created without thread id", chat_id=chat["chat_id"])
continue
db.set_forum_thread(chat["chat_id"], thread_id)
created_topics += 1
logger.info(
"Forum topic linked to chat",
tg_user_id=tg_user_id,
chat_id=chat["chat_id"],
forum_group_id=forwarded_chat.id,
forum_thread_id=thread_id,
)
await state.set_state(ChatState.idle)
logger.info(
"Forum onboarding completed",
tg_user_id=tg_user_id,
forum_group_id=forwarded_chat.id,
created_topics=created_topics,
)
await message.answer(
f"✅ Группа подключена. Создал {created_topics} тем(ы) для существующих чатов.",
reply_markup=ReplyKeyboardRemove(),
)
@router.message(Command("forum"))
async def cmd_forum(message: Message, state: FSMContext) -> None:
await state.set_state(ForumSetupState.waiting_for_group)
logger.info("Forum onboarding started", tg_user_id=message.from_user.id)
await message.answer(
"Выбери forum-группу кнопкой ниже. Бот должен уже быть добавлен туда "
"администратором с правом управления темами.\n\n"
"Если кнопка не сработает, можно переслать сообщение из группы как fallback.",
reply_markup=forum_group_request_keyboard(),
)
@router.message(ForumSetupState.waiting_for_group)
async def handle_group_forward(message: Message, state: FSMContext) -> None:
chat_shared = getattr(message, "chat_shared", None)
if chat_shared is not None:
logger.info(
"Forum onboarding chat selected via request_chat",
tg_user_id=message.from_user.id,
forum_group_id=chat_shared.chat_id,
forum_group_title=getattr(chat_shared, "title", None),
request_id=getattr(chat_shared, "request_id", None),
)
forwarded_chat = Chat(
id=chat_shared.chat_id,
type="supergroup",
title=getattr(chat_shared, "title", None),
is_forum=True,
)
await _complete_group_link(message, state, forwarded_chat)
return
debug_payload = _forward_debug_payload(message)
logger.info(
"Forum onboarding message received",
tg_user_id=message.from_user.id,
**debug_payload,
)
forwarded_chat = _resolve_forwarded_chat(message)
if forwarded_chat is None:
logger.warning(
"Forum onboarding failed: missing forwarded chat metadata",
tg_user_id=message.from_user.id,
**debug_payload,
)
await message.answer(
"Не вижу в сообщении данных о группе. "
"Нажми кнопку `Выбрать forum-группу` или перешли сообщение именно из нужной супергруппы, не копируй текст вручную."
)
return
if forwarded_chat.type != "supergroup":
logger.warning(
"Forum onboarding failed: forwarded chat is not supergroup",
tg_user_id=message.from_user.id,
**debug_payload,
)
await message.answer(
"Пересылка пришла не из супергруппы. Нужна именно supergroup с включёнными Topics."
)
return
if getattr(forwarded_chat, "is_forum", None) is False:
logger.warning(
"Forum onboarding failed: supergroup is not forum-enabled",
tg_user_id=message.from_user.id,
**debug_payload,
)
await message.answer(
"Это супергруппа, но в ней выключены Topics. Включи Topics и попробуй снова."
)
return
await _complete_group_link(message, state, forwarded_chat)

View file

@ -0,0 +1,22 @@
from __future__ import annotations
from aiogram.types import KeyboardButton, KeyboardButtonRequestChat, ReplyKeyboardMarkup
def forum_group_request_keyboard() -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup(
keyboard=[[
KeyboardButton(
text="Выбрать forum-группу",
request_chat=KeyboardButtonRequestChat(
request_id=1,
chat_is_channel=False,
chat_is_forum=True,
bot_is_member=True,
request_title=True,
),
)
]],
resize_keyboard=True,
one_time_keyboard=True,
)

View file

@ -11,3 +11,7 @@ class SettingsState(StatesGroup):
menu = State() # Главное меню настроек
soul_editing = State() # Редактирует имя/инструкции агента
confirm_action = State() # Подтверждение деструктивного действия
class ForumSetupState(StatesGroup):
waiting_for_group = State() # Ждём пересылку сообщения из супергруппы