Improve Telegram forum onboarding and topic safety
This commit is contained in:
parent
2b56b98697
commit
a1b7a14138
13 changed files with 1101 additions and 376 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
212
adapter/telegram/handlers/forum.py
Normal file
212
adapter/telegram/handlers/forum.py
Normal 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)
|
||||||
22
adapter/telegram/keyboards/forum.py
Normal file
22
adapter/telegram/keyboards/forum.py
Normal 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,
|
||||||
|
)
|
||||||
|
|
@ -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() # Ждём пересылку сообщения из супергруппы
|
||||||
|
|
|
||||||
|
|
@ -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` — подтверждения
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
1
tests/adapter/__init__.py
Normal file
1
tests/adapter/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
1
tests/adapter/telegram/__init__.py
Normal file
1
tests/adapter/telegram/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
374
tests/adapter/telegram/test_forum.py
Normal file
374
tests/adapter/telegram/test_forum.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue