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() # Ждём пересылку сообщения из супергруппы

View file

@ -1,7 +1,7 @@
# Telegram Adapter Design
**Date:** 2026-03-31
**Status:** Approved — ready for implementation
**Status:** Approved — implemented in `feat/telegram-adapter`
**Scope:** `adapter/telegram/`
---
@ -10,38 +10,45 @@
Telegram-адаптер — поверхность для взаимодействия пользователя с AI-агентом Lambda через Telegram.
Адаптер конвертирует 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-режим поверх тех же самых чатов.
### Принцип работы
- `active_chat_id` — куда идут входящие сообщения от пользователя в данный момент
- Ответы от агента всегда приходят в общий DM-поток с тегом: `[Чат #1] Вот ответ...`
- Пользователь может переключиться до получения ответа — ответ всё равно придёт, тегирован
- В DM пользователь всегда может писать сразу после `/start`
- `active_chat_id` хранится в FSM и определяет, в какой чат идут DM-сообщения
- Если подключена Forum-группа, каждый чат может получить `forum_thread_id`
- Один и тот же `chat_id` доступен из двух поверхностей:
- DM: ответы идут с префиксом `[Название чата]`
- Forum-тема: ответы идут прямо в тему без префикса
### UX флоу
```
```text
/start
Приветствие + Чат #1 создан автоматически
Пользователь сразу пишет
пользователь аутентифицирован
создаётся или восстанавливается активный DM-чат
/new [название]
→ Новый чат создан, переключаемся на него
/new [название] в DM
→ создаётся новый чат
→ если forum уже подключён, бот создаёт и forum topic
/chats
→ Инлайн-кнопки: 1. Чат #1 2. Чат #2 3. Исследование рынка
→ Нажимает — переключился
/forum
→ бот просит переслать сообщение из супергруппы с Topics
→ проверяет admin rights
→ привязывает группу к пользователю
→ создаёт topics для существующих чатов
Сообщение в активный чат
→ Typing indicator
→ [Чат #1] Ответ агента
Сообщение в DM
→ идёт в active_chat_id
→ ответ приходит в 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)`
2. `is_new=True` → создать Чат #1, написать приветствие
3. `is_new=False` → восстановить `active_chat_id` из БД, написать "С возвращением"
1. `/start``platform.get_or_create_user(external_id=tg_user_id, platform="telegram", display_name=...)`
2. Бот сохраняет `tg_user_id -> platform_user_id` в локальной БД
3. Если локальных чатов ещё нет — создаёт `Чат #1`
4. Если чат уже есть — восстанавливает последний активный чат
### FSM состояния
```python
class AuthState(StatesGroup):
# В моке состояний нет — auth мгновенный
# Зарезервировано для реального SDK (waiting_confirmation и т.п.)
pass
```
---
## FSM состояния (полная схема)
```python
class ChatState(StatesGroup):
idle = State() # В активном чате, ждём сообщения
waiting_response = State() # Запрос ушёл на платформу, ждём ответа
idle = State()
waiting_response = State()
class SettingsState(StatesGroup):
menu = State() # Главное меню настроек
soul_editing = State() # Редактирует имя/инструкции агента
confirm_action = State() # Подтверждение деструктивного действия
menu = State()
soul_editing = 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/
bot.py — точка входа: Dispatcher, routers, middleware
states.py — FSM StatesGroup
converter.py — aiogram Message → IncomingEvent и обратно
bot.py — точка входа: Dispatcher, middleware, routers
converter.py — Message -> IncomingMessage, forum helpers, output formatting
db.py — SQLite schema и Telegram-specific persistence
states.py — ChatState, SettingsState, ForumSetupState
handlers/
auth.py — /start
chat.py — /new, /chats, /rename, /archive, сообщения в чате
settings.py — /settings и callback_query для настроек
confirm.py — подтверждение действий агента (InlineKeyboard ✅/❌)
chat.py — /new, /chats, switch chat, входящие сообщения
confirm.py — confirm/cancel callbacks
forum.py — /forum onboarding и регистрация forum group
settings.py — /settings и callbacks настроек
keyboards/
chat.py — список чатов, управление чатом
settings.py — меню настроек, скиллы, коннекторы
confirm.py — кнопки подтверждения действия
chat.py — список чатов
confirm.py — confirm keyboard
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
Конвертация в обе стороны — `adapter/telegram/converter.py`.
### aiogram → IncomingEvent
### Telegram -> IncomingEvent
```python
def from_message(message: Message) -> IncomingMessage:
def from_message(message: Message, chat_id: str) -> IncomingMessage:
return IncomingMessage(
user_id=str(message.from_user.id),
chat_id=active_chat_id, # из FSM StateData
text=message.text or "",
attachments=extract_attachments(message),
chat_id=chat_id,
text=message.text or message.caption or "",
attachments=_extract_attachments(message),
platform="telegram",
raw=message.model_dump(),
)
def extract_attachments(message: Message) -> list[Attachment]:
attachments = []
if message.photo:
file = message.photo[-1] # наибольшее разрешение
attachments.append(Attachment(
url=f"tg://file/{file.file_id}", # резолвим через getFile при необходимости
mime_type="image/jpeg",
size=file.file_size,
))
if message.document:
attachments.append(Attachment(
url=f"tg://file/{message.document.file_id}",
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
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)
return chat["chat_id"] if chat else None
```
### OutgoingEvent Telegram
### OutgoingEvent -> Telegram
```python
async def send_outgoing(bot: Bot, user_id: int, chat_name: str, event: OutgoingEvent) -> None:
prefix = f"[{chat_name}] "
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)
def format_outgoing(chat_name: str, event: OutgoingEvent, *, prefix: bool = True) -> str:
rendered_prefix = f"[{chat_name}] " if prefix else ""
return rendered_prefix + event.text
```
- DM-ответы используют `prefix=True`
- Forum-ответы используют `prefix=False`
- `OutgoingUI` отправляется с inline-кнопками подтверждения
---
## Обработчики
### auth.py`/start`
### `auth.py`
```python
@router.message(CommandStart())
async def cmd_start(message: Message, state: FSMContext, platform: PlatformClient):
user = await platform.get_or_create_user(
external_id=str(message.from_user.id),
platform="telegram",
display_name=message.from_user.full_name,
)
- `/start` создаёт или восстанавливает пользователя
- если это первый запуск, создаёт `Чат #1`
- обновляет `active_chat_id` и переводит FSM в `ChatState.idle`
if user.is_new:
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`
### 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
@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"]
### `forum.py`
await state.set_state(ChatState.waiting_response)
await message.bot.send_chat_action(message.chat.id, "typing")
- `/forum` переводит FSM в `ForumSetupState.waiting_for_group`
- пересланное сообщение из супергруппы:
- валидирует, что это `supergroup`
- проверяет, что бот admin и умеет `can_manage_topics`
- сохраняет `forum_group_id`
- создаёт topics для существующих чатов без `forum_thread_id`
incoming = from_message(message, chat_id)
outgoing_events = await core_handler.handle(incoming, platform)
### `confirm.py`
await state.set_state(ChatState.idle)
for event in outgoing_events:
await send_outgoing(message.bot, message.from_user.id, chat_name, event)
```
### 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()
```
- обрабатывает `confirm:yes:<action_id>` и `confirm:no:<action_id>`
- в forum-режиме восстанавливает `chat_id` по thread
- ответ на callback отправляет обратно в тот же канал:
- DM -> в личку
- Forum -> в тот же `message_thread_id`
---
## Настройки
## Текущее покрытие
`/settings` → инлайн-меню. Структура:
```
⚙️ Настройки
[🧩 Скиллы] [🔗 Коннекторы]
[🧠 Личность] [🔒 Безопасность]
[💳 Подписка]
```
**Скиллы** — список с кнопками-переключателями ✅/❌. Нажатие → `SettingsAction(toggle_skill)`.
**Личность** — свободные поля (имя агента, инструкции). Без пресетов стилей.
FSM: `SettingsState.soul_editing` → бот задаёт вопросы по одному полю.
**Коннекторы** — заглушка OAuth ссылки.
**Безопасность** — переключатели для деструктивных действий.
**Подписка** — заглушка с токенами.
- 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`
---
## Хранилище (БД)
## Что не покрывает этот документ
Минимальная схема для прототипа:
```sql
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` — подтверждения
- Matrix-адаптер
- Реальный SDK платформы вместо `sdk.mock.MockPlatformClient`
- Автоматическое отслеживание вручную созданных пользователем forum topics без `/new`

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