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

View file

@ -3,6 +3,7 @@ from __future__ import annotations
from aiogram.types import Message from aiogram.types import Message
from adapter.telegram import db
from core.protocol import Attachment, IncomingMessage, OutgoingEvent, OutgoingMessage, OutgoingUI 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]: def _extract_attachments(message: Message) -> list[Attachment]:
attachments: list[Attachment] = [] attachments: list[Attachment] = []
if message.photo: if message.photo:
@ -41,10 +57,10 @@ def _extract_attachments(message: Message) -> list[Attachment]:
return attachments return attachments
def format_outgoing(chat_name: str, event: OutgoingEvent) -> str: def format_outgoing(chat_name: str, event: OutgoingEvent, *, prefix: bool = True) -> str:
prefix = f"[{chat_name}] " rendered_prefix = f"[{chat_name}] " if prefix else ""
if isinstance(event, OutgoingMessage): if isinstance(event, OutgoingMessage):
return prefix + event.text return rendered_prefix + event.text
if isinstance(event, OutgoingUI): if isinstance(event, OutgoingUI):
return prefix + event.text return rendered_prefix + event.text
return prefix + str(event) return rendered_prefix + str(event)

View file

@ -27,18 +27,29 @@ def init_db() -> None:
tg_user_id INTEGER PRIMARY KEY, tg_user_id INTEGER PRIMARY KEY,
platform_user_id TEXT NOT NULL, platform_user_id TEXT NOT NULL,
display_name TEXT, 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 ( CREATE TABLE IF NOT EXISTS chats (
chat_id TEXT PRIMARY KEY, chat_id TEXT PRIMARY KEY,
tg_user_id INTEGER NOT NULL, tg_user_id INTEGER NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
archived_at TIMESTAMP, archived_at TIMESTAMP,
forum_thread_id INTEGER,
FOREIGN KEY(tg_user_id) REFERENCES tg_users(tg_user_id) 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( 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 = ?", "UPDATE chats SET archived_at = CURRENT_TIMESTAMP WHERE chat_id = ?",
(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 asyncio
import structlog
from aiogram import F, Router from aiogram import F, Router
from aiogram.filters import Command, CommandObject from aiogram.filters import Command
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message from aiogram.types import CallbackQuery, Message
from adapter.telegram import db 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.chat import chats_list_keyboard
from adapter.telegram.keyboards.confirm import confirm_keyboard
from adapter.telegram.states import ChatState from adapter.telegram.states import ChatState
from core.handler import EventDispatcher from core.handler import EventDispatcher
from core.protocol import OutgoingMessage, OutgoingUI from core.protocol import OutgoingMessage, OutgoingUI
from adapter.telegram.keyboards.confirm import confirm_keyboard
logger = structlog.get_logger(__name__)
router = Router(name="chat") 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: for event in events:
if isinstance(event, OutgoingUI): if isinstance(event, OutgoingUI):
from adapter.telegram.keyboards.confirm import confirm_keyboard action_id = (
action_id = event.buttons[0].payload.get("action_id", "unknown") if event.buttons else "unknown" event.buttons[0].payload.get("action_id", "unknown")
if event.buttons
else "unknown"
)
kb = confirm_keyboard(action_id) 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): 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("/")) @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, dispatcher: EventDispatcher,
) -> None: ) -> None:
data = await state.get_data() data = await state.get_data()
chat_id = data.get("active_chat_id") tg_id = message.from_user.id
chat_name = data.get("active_chat_name", "Чат") 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: if not chat_id:
await message.answer("Нет активного чата. Введите /start") await message.answer("Нет активного чата. Введите /start")
@ -46,18 +122,21 @@ async def handle_message(
await state.set_state(ChatState.waiting_response) await state.set_state(ChatState.waiting_response)
# Typing indicator loop async def _typing_loop() -> None:
async def _typing_loop():
while True: 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) await asyncio.sleep(4)
task = asyncio.create_task(_typing_loop()) task = asyncio.create_task(_typing_loop())
events: list = []
try: 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 = from_message(message, chat_id)
incoming.user_id = platform_user_id incoming.user_id = platform_user_id
events = await dispatcher.dispatch(incoming) events = await dispatcher.dispatch(incoming)
@ -69,7 +148,13 @@ async def handle_message(
pass pass
await state.set_state(ChatState.idle) 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")) @router.message(Command("new"))
@ -77,18 +162,79 @@ async def cmd_new_chat(message: Message, state: FSMContext) -> None:
tg_id = message.from_user.id tg_id = message.from_user.id
args = message.text.split(maxsplit=1) args = message.text.split(maxsplit=1)
name = args[1].strip() if len(args) > 1 else None 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) count = db.count_chats(tg_id)
chat_name = name or f"Чат #{count + 1}" chat_name = name or f"Чат #{count + 1}"
chat_id = db.create_chat(tg_id, chat_name) 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.update_data(active_chat_id=chat_id, active_chat_name=chat_name)
await state.set_state(ChatState.idle) 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")) @router.message(Command("chats"))
async def cmd_list_chats(message: Message, state: FSMContext) -> None: 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 tg_id = message.from_user.id
chats = db.get_user_chats(tg_id) chats = db.get_user_chats(tg_id)
if not chats: if not chats:
@ -103,6 +249,13 @@ async def cmd_list_chats(message: Message, state: FSMContext) -> None:
@router.callback_query(F.data.startswith("switch:")) @router.callback_query(F.data.startswith("switch:"))
async def switch_chat(callback: CallbackQuery, state: FSMContext) -> None: 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) _, chat_id, chat_name = callback.data.split(":", 2)
await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name)
await state.set_state(ChatState.idle) 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") @router.callback_query(F.data == "new_chat")
async def cb_new_chat(callback: CallbackQuery, state: FSMContext) -> None: 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 tg_id = callback.from_user.id
count = db.count_chats(tg_id) count = db.count_chats(tg_id)
chat_name = f"Чат #{count + 1}" chat_name = f"Чат #{count + 1}"

View file

@ -5,12 +5,35 @@ from aiogram import F, Router
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery 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.handler import EventDispatcher
from core.protocol import IncomingCallback from core.protocol import IncomingCallback, OutgoingMessage, OutgoingUI
router = Router(name="confirm") 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:")) @router.callback_query(F.data.startswith("confirm:"))
async def handle_confirm( async def handle_confirm(
callback: CallbackQuery, callback: CallbackQuery,
@ -23,12 +46,21 @@ async def handle_confirm(
data = await state.get_data() data = await state.get_data()
chat_id = data.get("active_chat_id", "") chat_id = data.get("active_chat_id", "")
chat_name = data.get("active_chat_name", "Чат") 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_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)) 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( incoming = IncomingCallback(
user_id=platform_user_id, user_id=platform_user_id,
platform="telegram", platform="telegram",
@ -38,12 +70,12 @@ async def handle_confirm(
) )
events = await dispatcher.dispatch(incoming) 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: for event in events:
from core.protocol import OutgoingMessage, OutgoingUI
from adapter.telegram.converter import format_outgoing
if isinstance(event, (OutgoingMessage, OutgoingUI)): 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() 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() # Главное меню настроек menu = State() # Главное меню настроек
soul_editing = State() # Редактирует имя/инструкции агента soul_editing = State() # Редактирует имя/инструкции агента
confirm_action = State() # Подтверждение деструктивного действия confirm_action = State() # Подтверждение деструктивного действия
class ForumSetupState(StatesGroup):
waiting_for_group = State() # Ждём пересылку сообщения из супергруппы

View file

@ -1,7 +1,7 @@
# Telegram Adapter Design # Telegram Adapter Design
**Date:** 2026-03-31 **Date:** 2026-03-31
**Status:** Approved — ready for implementation **Status:** Approved — implemented in `feat/telegram-adapter`
**Scope:** `adapter/telegram/` **Scope:** `adapter/telegram/`
--- ---
@ -10,38 +10,45 @@
Telegram-адаптер — поверхность для взаимодействия пользователя с AI-агентом Lambda через Telegram. Telegram-адаптер — поверхность для взаимодействия пользователя с AI-агентом Lambda через Telegram.
Адаптер конвертирует Telegram-события в `IncomingEvent` (core protocol) и отправляет `OutgoingEvent` обратно. Адаптер конвертирует Telegram-события в `IncomingEvent` (core protocol) и отправляет `OutgoingEvent` обратно.
Бизнес-логика — в `core/`, адаптер только переводит форматы и управляет Telegram API. Бизнес-логика остаётся в `core/`; адаптер отвечает за Telegram API, FSM и локальную SQLite-привязку Telegram-пользователя и чатов.
--- ---
## Чаты: основной режим — Виртуальные чаты в DM ## Чаты: hybrid DM + Forum Topics
**Решение зафиксировано:** основной режим — виртуальные чаты прямо в личке с ботом. **Зафиксированное решение:** базовая поверхность — личка с ботом, Forum Topics — опциональный advanced-режим поверх тех же самых чатов.
Forum Topics — опциональный advanced режим (не реализуется в этом прототипе).
### Принцип работы - В DM пользователь всегда может писать сразу после `/start`
- `active_chat_id` хранится в FSM и определяет, в какой чат идут DM-сообщения
- `active_chat_id` — куда идут входящие сообщения от пользователя в данный момент - Если подключена Forum-группа, каждый чат может получить `forum_thread_id`
- Ответы от агента всегда приходят в общий DM-поток с тегом: `[Чат #1] Вот ответ...` - Один и тот же `chat_id` доступен из двух поверхностей:
- Пользователь может переключиться до получения ответа — ответ всё равно придёт, тегирован - DM: ответы идут с префиксом `[Название чата]`
- Forum-тема: ответы идут прямо в тему без префикса
### UX флоу ### UX флоу
``` ```text
/start /start
Приветствие + Чат #1 создан автоматически пользователь аутентифицирован
Пользователь сразу пишет создаётся или восстанавливается активный DM-чат
/new [название] /new [название] в DM
→ Новый чат создан, переключаемся на него → создаётся новый чат
→ если forum уже подключён, бот создаёт и forum topic
/chats /forum
→ Инлайн-кнопки: 1. Чат #1 2. Чат #2 3. Исследование рынка → бот просит переслать сообщение из супергруппы с Topics
→ Нажимает — переключился → проверяет admin rights
→ привязывает группу к пользователю
→ создаёт topics для существующих чатов
Сообщение в активный чат Сообщение в DM
→ Typing indicator → идёт в active_chat_id
→ [Чат #1] Ответ агента → ответ приходит в DM как `[Чат #N] ...`
Сообщение в forum topic
→ по `message_thread_id` определяется chat_id
→ ответ приходит в ту же тему без тега
``` ```
--- ---
@ -50,339 +57,180 @@ Forum Topics — опциональный advanced режим (не реализ
### Флоу (мок) ### Флоу (мок)
1. `/start``get_or_create_user(tg_user_id, "telegram", display_name)` 1. `/start``platform.get_or_create_user(external_id=tg_user_id, platform="telegram", display_name=...)`
2. `is_new=True` → создать Чат #1, написать приветствие 2. Бот сохраняет `tg_user_id -> platform_user_id` в локальной БД
3. `is_new=False` → восстановить `active_chat_id` из БД, написать "С возвращением" 3. Если локальных чатов ещё нет — создаёт `Чат #1`
4. Если чат уже есть — восстанавливает последний активный чат
### FSM состояния ### FSM состояния
```python
class AuthState(StatesGroup):
# В моке состояний нет — auth мгновенный
# Зарезервировано для реального SDK (waiting_confirmation и т.п.)
pass
```
---
## FSM состояния (полная схема)
```python ```python
class ChatState(StatesGroup): class ChatState(StatesGroup):
idle = State() # В активном чате, ждём сообщения idle = State()
waiting_response = State() # Запрос ушёл на платформу, ждём ответа waiting_response = State()
class SettingsState(StatesGroup): class SettingsState(StatesGroup):
menu = State() # Главное меню настроек menu = State()
soul_editing = State() # Редактирует имя/инструкции агента soul_editing = State()
confirm_action = State() # Подтверждение деструктивного действия confirm_action = State()
class ForumSetupState(StatesGroup):
waiting_for_group = State()
``` ```
**`active_chat_id` хранится в FSM StateData, не в состоянии.** `active_chat_id` и `active_chat_name` хранятся в `FSMContext` data.
--- ---
## Структура файлов ## Структура файлов
``` ```text
adapter/telegram/ adapter/telegram/
bot.py — точка входа: Dispatcher, routers, middleware bot.py — точка входа: Dispatcher, middleware, routers
states.py — FSM StatesGroup converter.py — Message -> IncomingMessage, forum helpers, output formatting
converter.py — aiogram Message → IncomingEvent и обратно db.py — SQLite schema и Telegram-specific persistence
states.py — ChatState, SettingsState, ForumSetupState
handlers/ handlers/
auth.py — /start auth.py — /start
chat.py — /new, /chats, /rename, /archive, сообщения в чате chat.py — /new, /chats, switch chat, входящие сообщения
settings.py — /settings и callback_query для настроек confirm.py — confirm/cancel callbacks
confirm.py — подтверждение действий агента (InlineKeyboard ✅/❌) forum.py — /forum onboarding и регистрация forum group
settings.py — /settings и callbacks настроек
keyboards/ keyboards/
chat.py — список чатов, управление чатом chat.py — список чатов
settings.py — меню настроек, скиллы, коннекторы confirm.py — confirm keyboard
confirm.py — кнопки подтверждения действия settings.py — меню настроек
``` ```
--- ---
## Persistence
Локальная БД содержит две Telegram-специфичные сущности:
```sql
CREATE TABLE tg_users (
tg_user_id INTEGER PRIMARY KEY,
platform_user_id TEXT NOT NULL,
display_name TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
forum_group_id INTEGER
);
CREATE TABLE 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,
forum_thread_id INTEGER
);
```
- `forum_group_id` — привязанная супергруппа пользователя
- `forum_thread_id` — опциональная связь конкретного чата с forum topic
---
## Converter ## Converter
Конвертация в обе стороны — `adapter/telegram/converter.py`. ### Telegram -> IncomingEvent
### aiogram → IncomingEvent
```python ```python
def from_message(message: Message) -> IncomingMessage: def from_message(message: Message, chat_id: str) -> IncomingMessage:
return IncomingMessage( return IncomingMessage(
user_id=str(message.from_user.id), user_id=str(message.from_user.id),
chat_id=active_chat_id, # из FSM StateData chat_id=chat_id,
text=message.text or "", text=message.text or message.caption or "",
attachments=extract_attachments(message), attachments=_extract_attachments(message),
platform="telegram", platform="telegram",
raw=message.model_dump(),
) )
def extract_attachments(message: Message) -> list[Attachment]:
attachments = [] def is_forum_message(message: Message) -> bool:
if message.photo: return getattr(message, "message_thread_id", None) is not None
file = message.photo[-1] # наибольшее разрешение
attachments.append(Attachment(
url=f"tg://file/{file.file_id}", # резолвим через getFile при необходимости def resolve_forum_chat_id(message: Message, tg_user_id: int) -> str | None:
mime_type="image/jpeg", thread_id = getattr(message, "message_thread_id", None)
size=file.file_size, if thread_id is None:
)) return None
if message.document:
attachments.append(Attachment( chat = db.get_chat_by_thread(tg_user_id, thread_id)
url=f"tg://file/{message.document.file_id}", return chat["chat_id"] if chat else None
mime_type=message.document.mime_type or "application/octet-stream",
size=message.document.file_size,
filename=message.document.file_name,
))
if message.voice:
attachments.append(Attachment(
url=f"tg://file/{message.voice.file_id}",
mime_type="audio/ogg",
size=message.voice.file_size,
))
return attachments
``` ```
### OutgoingEvent Telegram ### OutgoingEvent -> Telegram
```python ```python
async def send_outgoing(bot: Bot, user_id: int, chat_name: str, event: OutgoingEvent) -> None: def format_outgoing(chat_name: str, event: OutgoingEvent, *, prefix: bool = True) -> str:
prefix = f"[{chat_name}] " rendered_prefix = f"[{chat_name}] " if prefix else ""
return rendered_prefix + event.text
if isinstance(event, OutgoingMessage):
await bot.send_message(user_id, prefix + event.text)
elif isinstance(event, OutgoingUI):
# Кнопки подтверждения действия
keyboard = build_confirm_keyboard(event)
await bot.send_message(user_id, prefix + event.text, reply_markup=keyboard)
``` ```
- DM-ответы используют `prefix=True`
- Forum-ответы используют `prefix=False`
- `OutgoingUI` отправляется с inline-кнопками подтверждения
--- ---
## Обработчики ## Обработчики
### auth.py`/start` ### `auth.py`
```python - `/start` создаёт или восстанавливает пользователя
@router.message(CommandStart()) - если это первый запуск, создаёт `Чат #1`
async def cmd_start(message: Message, state: FSMContext, platform: PlatformClient): - обновляет `active_chat_id` и переводит FSM в `ChatState.idle`
user = await platform.get_or_create_user(
external_id=str(message.from_user.id),
platform="telegram",
display_name=message.from_user.full_name,
)
if user.is_new: ### `chat.py`
chat_id = create_chat(user.user_id, "Чат #1") # в локальной БД
await state.update_data(active_chat_id=chat_id, active_chat_name="Чат #1")
await state.set_state(ChatState.idle)
await message.answer(
f"Привет, {message.from_user.first_name}! 👋\n"
f"Я создал тебе первый чат. Просто пиши.\n\n"
f"Команды: /new — новый чат, /chats — список"
)
else:
# Восстановить последний активный чат
last_chat = get_last_chat(user.user_id)
await state.update_data(active_chat_id=last_chat.id, active_chat_name=last_chat.name)
await state.set_state(ChatState.idle)
await message.answer(f"С возвращением! Продолжаем [{last_chat.name}]")
```
### chat.py — сообщения - `/new`:
- в DM создаёт новый чат
- если подключён forum, пытается создать forum topic и сохранить `forum_thread_id`
- в forum-теме может зарегистрировать текущую тему как чат
- `/chats` показывает inline-список чатов
- `switch:<chat_id>:<name>` переключает активный DM-чат
- `handle_message`:
- в DM читает `active_chat_id` из FSM
- в forum определяет чат по `message_thread_id`
- отправляет `typing`
- прокидывает `IncomingMessage` в `EventDispatcher`
- возвращает ответ в DM или в тему
```python ### `forum.py`
@router.message(ChatState.idle, F.text)
async def handle_message(message: Message, state: FSMContext, platform: PlatformClient):
data = await state.get_data()
chat_id = data["active_chat_id"]
chat_name = data["active_chat_name"]
await state.set_state(ChatState.waiting_response) - `/forum` переводит FSM в `ForumSetupState.waiting_for_group`
await message.bot.send_chat_action(message.chat.id, "typing") - пересланное сообщение из супергруппы:
- валидирует, что это `supergroup`
- проверяет, что бот admin и умеет `can_manage_topics`
- сохраняет `forum_group_id`
- создаёт topics для существующих чатов без `forum_thread_id`
incoming = from_message(message, chat_id) ### `confirm.py`
outgoing_events = await core_handler.handle(incoming, platform)
await state.set_state(ChatState.idle) - обрабатывает `confirm:yes:<action_id>` и `confirm:no:<action_id>`
- в forum-режиме восстанавливает `chat_id` по thread
for event in outgoing_events: - ответ на callback отправляет обратно в тот же канал:
await send_outgoing(message.bot, message.from_user.id, chat_name, event) - DM -> в личку
``` - Forum -> в тот же `message_thread_id`
### chat.py — управление чатами
```python
@router.message(Command("new"))
async def cmd_new_chat(message: Message, state: FSMContext):
args = message.text.split(maxsplit=1)
name = args[1] if len(args) > 1 else None
data = await state.get_data()
user_id = ... # из платформы
count = count_chats(user_id)
chat_name = name or f"Чат #{count + 1}"
chat_id = create_chat(user_id, chat_name)
await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name)
await message.answer(f"✅ [{chat_name}] создан. Пиши!")
@router.message(Command("chats"))
async def cmd_list_chats(message: Message, state: FSMContext):
chats = get_user_chats(user_id)
data = await state.get_data()
active_id = data.get("active_chat_id")
buttons = []
for chat in chats:
mark = "● " if chat.id == active_id else ""
buttons.append([InlineKeyboardButton(
text=f"{mark}{chat.name}",
callback_data=f"switch:{chat.id}:{chat.name}"
)])
buttons.append([InlineKeyboardButton(text=" Новый чат", callback_data="new_chat")])
await message.answer("Твои чаты:", reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
@router.callback_query(F.data.startswith("switch:"))
async def switch_chat(callback: CallbackQuery, state: FSMContext):
_, chat_id, chat_name = callback.data.split(":", 2)
await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name)
await callback.message.edit_text(f"✅ Переключился на [{chat_name}]")
await callback.answer()
```
### confirm.py — подтверждение действий агента
```python
# Агент хочет выполнить действие → OutgoingUI приходит из core handler
# Бот показывает кнопки, ждёт ответа
@router.callback_query(F.data.startswith("confirm:"))
async def handle_confirm(callback: CallbackQuery, state: FSMContext, platform: PlatformClient):
_, action_id, decision = callback.data.split(":") # "confirm" / "cancel"
data = await state.get_data()
incoming = IncomingCallback(
user_id=...,
chat_id=data["active_chat_id"],
action="confirm" if decision == "yes" else "cancel",
payload={"action_id": action_id},
platform="telegram",
)
outgoing_events = await core_handler.handle(incoming, platform)
await callback.message.edit_reply_markup(reply_markup=None)
for event in outgoing_events:
await send_outgoing(callback.bot, callback.from_user.id, data["active_chat_name"], event)
await callback.answer()
```
--- ---
## Настройки ## Текущее покрытие
`/settings` → инлайн-меню. Структура: - unit-тесты на forum routing и forum onboarding: `tests/adapter/telegram/test_forum.py`
- smoke/integration на dispatcher и core handlers:
``` - `tests/core/test_dispatcher.py`
⚙️ Настройки - `tests/core/test_integration.py`
[🧩 Скиллы] [🔗 Коннекторы]
[🧠 Личность] [🔒 Безопасность]
[💳 Подписка]
```
**Скиллы** — список с кнопками-переключателями ✅/❌. Нажатие → `SettingsAction(toggle_skill)`.
**Личность** — свободные поля (имя агента, инструкции). Без пресетов стилей.
FSM: `SettingsState.soul_editing` → бот задаёт вопросы по одному полю.
**Коннекторы** — заглушка OAuth ссылки.
**Безопасность** — переключатели для деструктивных действий.
**Подписка** — заглушка с токенами.
--- ---
## Хранилище (БД) ## Что не покрывает этот документ
Минимальная схема для прототипа: - Matrix-адаптер
- Реальный SDK платформы вместо `sdk.mock.MockPlatformClient`
```sql - Автоматическое отслеживание вручную созданных пользователем forum topics без `/new`
CREATE TABLE tg_users (
tg_user_id INTEGER PRIMARY KEY,
platform_user_id TEXT NOT NULL, -- из MockPlatformClient
display_name TEXT,
created_at TIMESTAMP
);
CREATE TABLE chats (
chat_id TEXT PRIMARY KEY, -- UUID
tg_user_id INTEGER NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMP,
archived_at TIMESTAMP,
FOREIGN KEY(tg_user_id) REFERENCES tg_users(tg_user_id)
);
```
`StateStore` из `core/store.py` (`SQLiteStore`) — для FSM и общего состояния.
---
## Typing indicator
Отправлять `send_chat_action("typing")` перед запросом к платформе.
Если запрос > 5 сек — возобновлять каждые 4 сек (action живёт ~5 сек).
```python
async def with_typing(bot: Bot, chat_id: int, coro):
async def renew():
while True:
await bot.send_chat_action(chat_id, "typing")
await asyncio.sleep(4)
task = asyncio.create_task(renew())
try:
return await coro
finally:
task.cancel()
```
---
## Обработка ответов при смене чата
Ответ всегда приходит в DM-поток с тегом:
```
[Чат #1] Вот мой ответ на вопрос про Python...
```
Пользователь мог переключить `active_chat_id` пока шёл запрос — это нормально.
`chat_name` берётся из `StateData` в момент отправки запроса (до `set_state(waiting_response)`).
---
## Что НЕ реализуем в прототипе
- Forum Topics режим (researched, отложено)
- Webhook от платформы (platform ещё не готов — используем sync `send_message`)
- `/rename`, `/archive` для чатов (добавить после основного флоу)
- Экспорт истории
---
## Порядок реализации
1. `bot.py` — Dispatcher, middleware для platform client
2. `states.py` — FSM классы
3. `converter.py` — from_message, extract_attachments
4. `handlers/auth.py` — /start
5. `handlers/chat.py` — сообщения + /new + /chats
6. `keyboards/chat.py` — список чатов
7. `handlers/settings.py` + `keyboards/settings.py` — меню настроек
8. `handlers/confirm.py` + `keyboards/confirm.py` — подтверждения

View file

@ -2,14 +2,17 @@
## Концепция ## Концепция
Один бот, несколько чатов через Topics в Forum-группе. Один бот, несколько чатов, две поверхности:
При первом запуске бот создаёт для пользователя персональную Forum-группу - базовая поверхность — личка с ботом (DM)
(супергруппу с включёнными темами). Каждый новый чат с агентом — отдельная тема - опциональная advanced-поверхность — Topics в пользовательской Forum-группе
внутри группы. Пользователь видит это как список чатов в одном месте.
Бот управляет группой от имени пользователя через Telegram Bot API: При первом запуске пользователь начинает в DM: бот создаёт первый чат и
создаёт темы, переименовывает, архивирует. переключает пользователя в него. Если позже пользователь подключает Forum-группу
через `/forum`, существующие чаты получают соответствующие темы в супергруппе.
DM и Forum используют один и тот же `chat_id`: пользователь может писать
либо в личке, либо в forum topic, а платформа видит единый разговор.
--- ---
@ -29,44 +32,51 @@
--- ---
## Чаты через Forum Topics (вариант В) ## Чаты в DM и Forum Topics
### Как это работает ### Как это работает
- Бот создаёт супергруппу с Topics для каждого нового пользователя - После `/start` бот создаёт `Чат #1` в DM
- Каждый чат = отдельная тема (Topic) в этой группе - В DM активный чат хранится как `active_chat_id`
- История хранится нативно в Telegram (в самой теме) - Ответы в личке приходят в общий поток с тегом `[Чат #N]`
- Переключение между чатами = переключение между темами - После `/forum` пользователь привязывает свою супергруппу с Topics
- Каждый чат может получить соответствующую тему (`forum_thread_id`)
- В forum-теме ответы приходят без тега, прямо в тему
- История forum-разговоров хранится нативно в Telegram
### Управление чатами ### Управление чатами
Внутри каждой темы доступны команды: В DM доступны команды:
| Команда | Действие | | Команда | Действие |
|---|---| |---|---|
| `/new` | Создать новый чат (новую тему) | | `/new` | Создать новый чат |
| `/rename Название` | Переименовать текущий чат |
| `/archive` | Архивировать текущий чат |
| `/chats` | Показать список всех чатов | | `/chats` | Показать список всех чатов |
| `/forum` | Подключить Forum-группу |
В forum-темах поддерживается тот же разговорный контекст, а `/new` может
зарегистрировать текущую тему как отдельный чат.
### Создание нового чата ### Создание нового чата
1. Пользователь пишет `/new` или нажимает кнопку 1. Пользователь пишет `/new [название]` или нажимает кнопку
2. Бот спрашивает название (опционально, можно пропустить) 2. Бот создаёт новый чат в локальной БД: `Чат #N` или указанное название
3. Бот создаёт новую тему в группе: «Чат 1», «Чат 2» и т.д. 3. Если Forum уже подключён, бот дополнительно создаёт новую тему в привязанной группе
4. Бот отправляет в новую тему приветствие; при первом сообщении платформа автоматически поднимает контейнер 4. В DM бот переключает `active_chat_id` на новый чат
### В моке ### В моке
- Группа и темы создаются реально через Bot API - DM-чаты работают сразу после `/start`
- Сообщения передаются в MockPlatformClient с `chat_id` (C1, C2...) - Если Forum подключён, темы создаются реально через Bot API
- История в темах хранится нативно в Telegram, ничего не нужно делать - Сообщения из DM и forum topic передаются в `MockPlatformClient` с одним и тем же `chat_id`
--- ---
## Основной диалог ## Основной диалог
### Флоу сообщения ### Флоу сообщения
1. Пользователь пишет текст в тему 1. Пользователь пишет текст в DM или в forum topic
2. Бот показывает `typing...` 2. Бот показывает `typing...`
3. Запрос уходит в платформу (сейчас — MockPlatformClient) 3. Запрос уходит в платформу (сейчас — MockPlatformClient)
4. Бот отвечает текстом агента 4. Бот отвечает:
- в DM: с тегом `[Чат #N]`
- в forum topic: без тега, в ту же тему
### Вложения ### Вложения
- Фото, документы, голосовые — передаются в платформу как `attachments` - Фото, документы, голосовые — передаются в платформу как `attachments`
@ -92,7 +102,7 @@
## Настройки ## Настройки
Доступны через `/settings` в любой теме или в главном меню бота. Доступны через `/settings` в личке или в forum topic.
Реализованы как цепочка инлайн-кнопок. Реализованы как цепочка инлайн-кнопок.
### Главное меню настроек ### Главное меню настроек
@ -198,16 +208,14 @@
## FSM состояния ## FSM состояния
``` ```text
[Start] → AuthPending → AuthConfirmed [Start] -> ChatState.idle
GroupSetup → Idle ForumSetupState.waiting_for_group
ReceivingMessage → WaitingResponse → Idle ChatState.waiting_response -> ChatState.idle
ConfirmAction → [Confirmed/Cancelled] → Idle SettingsState.*
Settings → [подменю] → Idle
``` ```
--- ---
@ -216,6 +224,6 @@
- Python 3.11+ - Python 3.11+
- aiogram 3.x (Router, FSM, InlineKeyboard, Forum Topics API) - aiogram 3.x (Router, FSM, InlineKeyboard, Forum Topics API)
- MockPlatformClient → `platform/interface.py` - MockPlatformClient → `sdk/mock.py`
- structlog для логирования - structlog для логирования
- SQLite для хранения `tg_user_id → platform_user_id` и состояния скиллов - SQLite для хранения `tg_user_id → platform_user_id`, чатов и forum bindings

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