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

@ -0,0 +1 @@

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,374 @@
from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from aiogram.fsm.context import FSMContext
from aiogram.types import Chat, MessageOriginChat
from adapter.telegram.converter import is_forum_message, resolve_forum_chat_id
from adapter.telegram.handlers import chat as chat_handler
from adapter.telegram.handlers import confirm as confirm_handler
from adapter.telegram.handlers import forum as forum_handler
from adapter.telegram.states import ChatState, ForumSetupState
from core.protocol import OutgoingMessage
def make_message(*, text: str = "hello", thread_id: int | None = None):
message = SimpleNamespace()
message.text = text
message.caption = None
message.photo = None
message.document = None
message.voice = None
message.message_thread_id = thread_id
message.chat = SimpleNamespace(id=-100123)
message.from_user = SimpleNamespace(id=42, full_name="Alice", first_name="Alice")
message.answer = AsyncMock()
message.edit_text = AsyncMock()
message.edit_reply_markup = AsyncMock()
message.bot = SimpleNamespace(
send_message=AsyncMock(),
send_chat_action=AsyncMock(),
create_forum_topic=AsyncMock(),
get_me=AsyncMock(),
get_chat_member=AsyncMock(),
)
message.chat_shared = None
return message
class FakeTask:
def cancel(self) -> None:
self.cancelled = True
def __await__(self):
async def _done():
return None
return _done().__await__()
async def test_forum_helpers_detect_and_resolve(monkeypatch):
message = make_message(thread_id=77)
monkeypatch.setattr(
chat_handler.db,
"get_chat_by_thread",
lambda tg_user_id, thread_id: {"chat_id": "chat-77"} if thread_id == 77 else None,
)
assert is_forum_message(message) is True
assert resolve_forum_chat_id(message, 42) == "chat-77"
async def test_cmd_forum_enters_setup_state():
message = make_message(text="/forum")
state = AsyncMock(spec=FSMContext)
await forum_handler.cmd_forum(message, state)
state.set_state.assert_awaited_once_with(ForumSetupState.waiting_for_group)
message.answer.assert_awaited_once()
assert message.answer.await_args.kwargs["reply_markup"] is not None
async def test_handle_group_forward_registers_group_and_topics(monkeypatch):
message = make_message(text="forwarded")
message.forward_from_chat = SimpleNamespace(id=-100200, type="supergroup", title="Lambda")
message.bot.get_me.return_value = SimpleNamespace(id=999)
message.bot.get_chat_member.return_value = SimpleNamespace(
status="administrator",
can_manage_topics=True,
)
message.bot.create_forum_topic.side_effect = [
SimpleNamespace(message_thread_id=11),
SimpleNamespace(message_thread_id=22),
]
state = AsyncMock(spec=FSMContext)
monkeypatch.setattr(
forum_handler.db,
"get_user_chats",
lambda tg_user_id: [
{"chat_id": "chat-1", "name": "One", "forum_thread_id": None},
{"chat_id": "chat-2", "name": "Two", "forum_thread_id": None},
],
)
set_forum_group = Mock()
set_forum_thread = Mock()
monkeypatch.setattr(forum_handler.db, "set_forum_group", set_forum_group)
monkeypatch.setattr(forum_handler.db, "set_forum_thread", set_forum_thread)
await forum_handler.handle_group_forward(message, state)
set_forum_group.assert_called_once_with(42, -100200)
assert message.bot.create_forum_topic.await_count == 2
set_forum_thread.assert_any_call("chat-1", 11)
set_forum_thread.assert_any_call("chat-2", 22)
state.set_state.assert_awaited_once_with(ChatState.idle)
assert "Группа подключена" in message.answer.await_args.args[0]
async def test_handle_group_forward_accepts_forward_origin_chat(monkeypatch):
message = make_message(text="forwarded")
message.forward_from_chat = None
message.forward_origin = MessageOriginChat(
date=datetime.now(),
sender_chat=Chat(id=-100200, type="supergroup", title="Lambda", is_forum=True),
)
message.bot.get_me.return_value = SimpleNamespace(id=999)
message.bot.get_chat_member.return_value = SimpleNamespace(
status="administrator",
can_manage_topics=True,
)
state = AsyncMock(spec=FSMContext)
monkeypatch.setattr(forum_handler.db, "get_user_chats", lambda tg_user_id: [])
set_forum_group = Mock()
monkeypatch.setattr(forum_handler.db, "set_forum_group", set_forum_group)
await forum_handler.handle_group_forward(message, state)
set_forum_group.assert_called_once_with(42, -100200)
state.set_state.assert_awaited_once_with(ChatState.idle)
assert "Группа подключена" in message.answer.await_args.args[0]
async def test_handle_group_forward_accepts_chat_shared(monkeypatch):
message = make_message(text="selected")
message.chat_shared = SimpleNamespace(request_id=1, chat_id=-100200, title="Lambda")
message.bot.get_me.return_value = SimpleNamespace(id=999)
message.bot.get_chat_member.return_value = SimpleNamespace(
status="administrator",
can_manage_topics=True,
)
state = AsyncMock(spec=FSMContext)
monkeypatch.setattr(forum_handler.db, "get_user_chats", lambda tg_user_id: [])
set_forum_group = Mock()
monkeypatch.setattr(forum_handler.db, "set_forum_group", set_forum_group)
await forum_handler.handle_group_forward(message, state)
set_forum_group.assert_called_once_with(42, -100200)
state.set_state.assert_awaited_once_with(ChatState.idle)
assert "Группа подключена" in message.answer.await_args.args[0]
async def test_handle_group_forward_reports_missing_forward_metadata():
message = make_message(text="not forwarded")
message.forward_from_chat = None
message.forward_origin = None
state = AsyncMock(spec=FSMContext)
await forum_handler.handle_group_forward(message, state)
message.answer.assert_awaited_once()
assert "данных о группе" in message.answer.await_args.args[0]
state.set_state.assert_not_awaited()
async def test_handle_group_forward_reports_non_forum_supergroup():
message = make_message(text="forwarded")
message.forward_from_chat = SimpleNamespace(
id=-100200,
type="supergroup",
title="Lambda",
is_forum=False,
)
state = AsyncMock(spec=FSMContext)
await forum_handler.handle_group_forward(message, state)
message.answer.assert_awaited_once()
assert "выключены Topics" in message.answer.await_args.args[0]
state.set_state.assert_not_awaited()
async def test_handle_message_routes_forum_thread(monkeypatch):
message = make_message(thread_id=77)
dispatcher = SimpleNamespace(
dispatch=AsyncMock(
return_value=[OutgoingMessage(chat_id="chat-77", text="ok")]
)
)
state = AsyncMock(spec=FSMContext)
state.get_data.return_value = {}
monkeypatch.setattr(
chat_handler.db,
"get_or_create_tg_user",
lambda tg_user_id, platform_user_id, display_name: {
"platform_user_id": "usr-42",
"display_name": display_name,
},
)
monkeypatch.setattr(
chat_handler.db,
"get_chat_by_thread",
lambda tg_user_id, thread_id: {"chat_id": "chat-77", "name": "Forum chat"},
)
monkeypatch.setattr(
chat_handler.db,
"get_chat_by_id",
lambda chat_id: {"chat_id": chat_id, "name": "Forum chat"},
)
monkeypatch.setattr(
chat_handler.asyncio,
"create_task",
lambda coro: (coro.close(), FakeTask())[1],
)
await chat_handler.handle_message(message, state, dispatcher)
incoming = dispatcher.dispatch.await_args.args[0]
assert incoming.chat_id == "chat-77"
assert incoming.user_id == "usr-42"
assert state.update_data.await_args.kwargs == {
"active_chat_id": "chat-77",
"active_chat_name": "Forum chat",
}
message.bot.send_message.assert_awaited_once()
assert message.bot.send_message.await_args.args[0] == -100123
assert message.bot.send_message.await_args.kwargs["message_thread_id"] == 77
assert message.bot.send_message.await_args.args[1] == "ok"
async def test_cmd_new_chat_creates_forum_topic_for_dm(monkeypatch):
message = make_message(text="/new Analysis")
state = AsyncMock(spec=FSMContext)
message.bot.create_forum_topic.return_value = SimpleNamespace(message_thread_id=333)
monkeypatch.setattr(chat_handler.db, "get_forum_group", lambda tg_user_id: -100200)
monkeypatch.setattr(chat_handler.db, "count_chats", lambda tg_user_id: 2)
create_chat = Mock(return_value="chat-3")
set_forum_thread = Mock()
monkeypatch.setattr(chat_handler.db, "create_chat", create_chat)
monkeypatch.setattr(chat_handler.db, "set_forum_thread", set_forum_thread)
await chat_handler.cmd_new_chat(message, state)
create_chat.assert_called_once_with(42, "Analysis")
message.bot.create_forum_topic.assert_awaited_once_with(chat_id=-100200, name="Analysis")
set_forum_thread.assert_called_once_with("chat-3", 333)
state.update_data.assert_awaited_once_with(active_chat_id="chat-3", active_chat_name="Analysis")
message.answer.assert_awaited_once()
assert "Форум-тема тоже создана" in message.answer.await_args.args[0]
async def test_cmd_new_chat_registers_topic(monkeypatch):
message = make_message(text="/new Research", thread_id=88)
state = AsyncMock(spec=FSMContext)
monkeypatch.setattr(
chat_handler.db,
"get_chat_by_thread",
lambda tg_user_id, thread_id: None,
)
monkeypatch.setattr(chat_handler.db, "count_chats", lambda tg_user_id: 4)
create_chat = Mock(return_value="chat-5")
set_forum_thread = Mock()
monkeypatch.setattr(chat_handler.db, "create_chat", create_chat)
monkeypatch.setattr(chat_handler.db, "set_forum_thread", set_forum_thread)
await chat_handler.cmd_new_chat(message, state)
create_chat.assert_called_once_with(42, "Research")
set_forum_thread.assert_called_once_with("chat-5", 88)
message.bot.send_message.assert_awaited_once()
assert message.bot.send_message.await_args.kwargs["message_thread_id"] == 88
state.update_data.assert_awaited_once_with(active_chat_id="chat-5", active_chat_name="Research")
async def test_cmd_list_chats_rejected_in_forum_topic():
message = make_message(text="/chats", thread_id=88)
state = AsyncMock(spec=FSMContext)
await chat_handler.cmd_list_chats(message, state)
message.bot.send_message.assert_awaited_once()
assert message.bot.send_message.await_args.kwargs["message_thread_id"] == 88
assert "отключено" in message.bot.send_message.await_args.args[1]
async def test_switch_chat_rejected_in_forum_topic():
callback = SimpleNamespace(
data="switch:chat-9:Other",
from_user=SimpleNamespace(id=42, full_name="Alice"),
message=make_message(thread_id=88),
answer=AsyncMock(),
)
state = AsyncMock(spec=FSMContext)
await chat_handler.switch_chat(callback, state)
state.update_data.assert_not_awaited()
callback.answer.assert_awaited_once_with(
"Переключение чатов доступно только в личке с ботом.",
show_alert=True,
)
async def test_new_chat_callback_rejected_in_forum_topic(monkeypatch):
callback = SimpleNamespace(
data="new_chat",
from_user=SimpleNamespace(id=42, full_name="Alice"),
message=make_message(thread_id=88),
answer=AsyncMock(),
)
state = AsyncMock(spec=FSMContext)
create_chat = Mock()
monkeypatch.setattr(chat_handler.db, "create_chat", create_chat)
await chat_handler.cb_new_chat(callback, state)
create_chat.assert_not_called()
state.update_data.assert_not_awaited()
callback.answer.assert_awaited_once_with(
"Создание нового чата из списка доступно только в личке с ботом.",
show_alert=True,
)
async def test_confirm_callback_routes_back_to_forum_thread(monkeypatch):
message = make_message(thread_id=77)
callback = SimpleNamespace(
data="confirm:yes:action-1",
from_user=message.from_user,
message=message,
answer=AsyncMock(),
)
dispatcher = SimpleNamespace(
dispatch=AsyncMock(
return_value=[OutgoingMessage(chat_id="chat-77", text="done")]
)
)
state = AsyncMock(spec=FSMContext)
state.get_data.return_value = {}
monkeypatch.setattr(
confirm_handler.db,
"get_or_create_tg_user",
lambda tg_user_id, platform_user_id, display_name: {
"platform_user_id": "usr-42",
"display_name": display_name,
},
)
monkeypatch.setattr(
confirm_handler.db,
"get_chat_by_thread",
lambda tg_user_id, thread_id: {"chat_id": "chat-77"},
)
monkeypatch.setattr(
confirm_handler.db,
"get_chat_by_id",
lambda chat_id: {"chat_id": chat_id, "name": "Forum chat"},
)
await confirm_handler.handle_confirm(callback, state, dispatcher)
assert dispatcher.dispatch.await_args.args[0].chat_id == "chat-77"
assert callback.message.bot.send_message.await_count == 1
assert callback.message.bot.send_message.await_args.args[1] == "done"
assert callback.message.bot.send_message.await_args.kwargs["message_thread_id"] == 77