Compare commits

...

8 commits

38 changed files with 4118 additions and 14 deletions

3
.gitignore vendored
View file

@ -21,6 +21,9 @@ build/
.vscode/
*.swp
# Visual brainstorming sessions
.superpowers/
# Tests
.pytest_cache/
.coverage

2
adapter/__init__.py Normal file
View file

@ -0,0 +1,2 @@
from __future__ import annotations

View file

@ -0,0 +1 @@
from __future__ import annotations

233
adapter/matrix/bot.py Normal file
View file

@ -0,0 +1,233 @@
from __future__ import annotations
import asyncio
import os
from dataclasses import dataclass
from pathlib import Path
import structlog
from nio import (
AsyncClient,
AsyncClientConfig,
InviteMemberEvent,
MatrixRoom,
ReactionEvent,
RoomMemberEvent,
RoomMessageText,
)
from dotenv import load_dotenv
from adapter.matrix.converter import from_reaction, from_room_event
from adapter.matrix.handlers import register_matrix_handlers
from adapter.matrix.handlers.auth import handle_invite
from adapter.matrix.room_router import resolve_chat_id
from core.auth import AuthManager
from core.chat import ChatManager
from core.handler import EventDispatcher
from core.handlers import register_all
from core.protocol import (
OutgoingEvent,
OutgoingMessage,
OutgoingNotification,
OutgoingTyping,
OutgoingUI,
)
from core.settings import SettingsManager
from core.store import InMemoryStore, SQLiteStore, StateStore
from sdk.mock import MockPlatformClient
logger = structlog.get_logger(__name__)
load_dotenv(Path(__file__).resolve().parents[2] / ".env")
@dataclass
class MatrixRuntime:
platform: MockPlatformClient
store: StateStore
chat_mgr: ChatManager
auth_mgr: AuthManager
settings_mgr: SettingsManager
dispatcher: EventDispatcher
def build_event_dispatcher(platform: MockPlatformClient, store: StateStore) -> EventDispatcher:
chat_mgr = ChatManager(platform, store)
auth_mgr = AuthManager(platform, store)
settings_mgr = SettingsManager(platform, store)
dispatcher = EventDispatcher(
platform=platform, chat_mgr=chat_mgr, auth_mgr=auth_mgr, settings_mgr=settings_mgr
)
register_all(dispatcher)
register_matrix_handlers(dispatcher, store=store)
return dispatcher
def build_runtime(
platform: MockPlatformClient | None = None,
store: StateStore | None = None,
client: AsyncClient | None = None,
) -> MatrixRuntime:
platform = platform or MockPlatformClient()
store = store or InMemoryStore()
chat_mgr = ChatManager(platform, store)
auth_mgr = AuthManager(platform, store)
settings_mgr = SettingsManager(platform, store)
dispatcher = EventDispatcher(
platform=platform, chat_mgr=chat_mgr, auth_mgr=auth_mgr, settings_mgr=settings_mgr
)
register_all(dispatcher)
register_matrix_handlers(dispatcher, client=client, store=store)
return MatrixRuntime(
platform=platform,
store=store,
chat_mgr=chat_mgr,
auth_mgr=auth_mgr,
settings_mgr=settings_mgr,
dispatcher=dispatcher,
)
class MatrixBot:
def __init__(self, client: AsyncClient, runtime: MatrixRuntime) -> None:
self.client = client
self.runtime = runtime
async def on_room_message(self, room: MatrixRoom, event: RoomMessageText) -> None:
if getattr(event, "sender", None) == self.client.user_id:
return
chat_id = await resolve_chat_id(self.runtime.store, room.room_id, event.sender)
incoming = from_room_event(event, room_id=room.room_id, chat_id=chat_id)
if incoming is None:
return
outgoing = await self.runtime.dispatcher.dispatch(incoming)
await self._send_all(room.room_id, outgoing)
async def on_reaction(self, room: MatrixRoom, event: ReactionEvent) -> None:
if getattr(event, "sender", None) == self.client.user_id:
return
chat_id = await resolve_chat_id(self.runtime.store, room.room_id, event.sender)
incoming = from_reaction(event, sender=event.sender, chat_id=chat_id)
if incoming is None:
return
outgoing = await self.runtime.dispatcher.dispatch(incoming)
await self._send_all(room.room_id, outgoing)
async def on_member(self, room: MatrixRoom, event: RoomMemberEvent) -> None:
if getattr(event, "sender", None) == self.client.user_id:
return
membership = getattr(event, "membership", None)
if membership == "invite":
await handle_invite(
self.client,
room,
event,
self.runtime.platform,
self.runtime.store,
self.runtime.auth_mgr,
)
async def _send_all(self, room_id: str, outgoing: list[OutgoingEvent]) -> None:
for event in outgoing:
await send_outgoing(self.client, room_id, event)
def _button_action_to_reaction(action: str) -> str | None:
if action in {"confirm", "ok", "accept"}:
return "👍"
if action in {"cancel", "reject", "deny"}:
return ""
return None
async def send_outgoing(client: AsyncClient, room_id: str, event: OutgoingEvent) -> None:
if isinstance(event, OutgoingTyping):
await client.room_typing(room_id, event.is_typing, timeout=25000)
return
if isinstance(event, OutgoingNotification):
body = f"[{event.level.upper()}] {event.text}"
await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body})
return
if isinstance(event, OutgoingMessage):
await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": event.text})
return
if isinstance(event, OutgoingUI):
body = event.text
buttons = []
for button in event.buttons:
buttons.append(f"{button.label}")
if buttons:
body = "\n".join([body, "", *buttons])
resp = await client.room_send(
room_id, "m.room.message", {"msgtype": "m.text", "body": body}
)
event_id = getattr(resp, "event_id", None)
if event_id:
for button in event.buttons:
reaction = _button_action_to_reaction(button.action)
if reaction:
await client.room_send(
room_id,
"m.reaction",
{
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": event_id,
"key": reaction,
}
},
)
return
async def main() -> None:
homeserver = os.environ.get("MATRIX_HOMESERVER")
user_id = os.environ.get("MATRIX_USER_ID")
device_id = os.environ.get("MATRIX_DEVICE_ID", "")
password = os.environ.get("MATRIX_PASSWORD")
token = os.environ.get("MATRIX_ACCESS_TOKEN")
db_path = os.environ.get("MATRIX_DB_PATH", "lambda_matrix.db")
store_path = os.environ.get("MATRIX_STORE_PATH", "matrix_store")
if not homeserver or not user_id:
raise RuntimeError("MATRIX_HOMESERVER and MATRIX_USER_ID are required")
client_config = AsyncClientConfig(
request_timeout=120,
max_timeouts=12,
max_limit_exceeded=20,
backoff_factor=0.5,
max_timeout_retry_wait_time=15,
)
client = AsyncClient(
homeserver,
user=user_id,
device_id=device_id,
store_path=store_path,
config=client_config,
)
runtime = build_runtime(store=SQLiteStore(db_path), client=client)
if token:
client.access_token = token
elif password:
await client.login(password=password, device_name="surfaces-bot")
bot = MatrixBot(client, runtime)
client.add_event_callback(bot.on_room_message, RoomMessageText)
client.add_event_callback(bot.on_reaction, ReactionEvent)
client.add_event_callback(bot.on_member, (InviteMemberEvent, RoomMemberEvent))
logger.info(
"Matrix bot starting",
homeserver=homeserver,
user_id=user_id,
store_path=store_path,
request_timeout=client_config.request_timeout,
)
try:
await client.sync_forever(timeout=30000)
finally:
await client.close()
if __name__ == "__main__":
asyncio.run(main())

143
adapter/matrix/converter.py Normal file
View file

@ -0,0 +1,143 @@
from __future__ import annotations
from typing import Any
from adapter.matrix.reactions import CANCEL_REACTION, CONFIRM_REACTION, reaction_to_skill_index
from core.protocol import (
Attachment,
IncomingCallback,
IncomingCommand,
IncomingEvent,
IncomingMessage,
)
PLATFORM = "matrix"
def extract_attachments(event: Any) -> list[Attachment]:
msgtype = getattr(event, "msgtype", None)
if msgtype is None:
content = getattr(event, "content", {}) or {}
msgtype = content.get("msgtype")
if msgtype == "m.image":
return [
Attachment(
type="image",
url=getattr(event, "url", None),
mime_type=getattr(event, "mimetype", None),
)
]
if msgtype == "m.file":
return [
Attachment(
type="document",
url=getattr(event, "url", None),
filename=getattr(event, "body", None),
mime_type=getattr(event, "mimetype", None),
)
]
if msgtype == "m.audio":
return [
Attachment(
type="audio",
url=getattr(event, "url", None),
mime_type=getattr(event, "mimetype", None),
)
]
if msgtype == "m.video":
return [
Attachment(
type="video",
url=getattr(event, "url", None),
mime_type=getattr(event, "mimetype", None),
)
]
return []
def from_command(body: str, sender: str, chat_id: str) -> IncomingEvent:
raw = body.lstrip("!").strip()
parts = raw.split()
command = parts[0].lower() if parts else ""
args = parts[1:]
if command in {"yes", "no"}:
action = "confirm" if command == "yes" else "cancel"
return IncomingCallback(
user_id=sender,
platform=PLATFORM,
chat_id=chat_id,
action=action,
payload={"source": "command", "command": command},
)
aliases = {
"skills": "settings_skills",
"connectors": "settings_connectors",
"soul": "settings_soul",
"safety": "settings_safety",
"plan": "settings_plan",
"status": "settings_status",
"whoami": "settings_whoami",
}
command = aliases.get(command, command)
return IncomingCommand(
user_id=sender,
platform=PLATFORM,
chat_id=chat_id,
command=command,
args=args,
)
def from_reaction(event: Any, sender: str, chat_id: str) -> IncomingCallback | None:
content = getattr(event, "content", {}) or {}
relates_to = content.get("m.relates_to", {})
key = getattr(event, "key", None) or relates_to.get("key")
event_id = getattr(event, "event_id", None) or relates_to.get("event_id")
if not key:
return None
if key == CONFIRM_REACTION:
return IncomingCallback(
user_id=sender,
platform=PLATFORM,
chat_id=chat_id,
action="confirm",
payload={"event_id": event_id, "reaction": key},
)
if key == CANCEL_REACTION:
return IncomingCallback(
user_id=sender,
platform=PLATFORM,
chat_id=chat_id,
action="cancel",
payload={"event_id": event_id, "reaction": key},
)
skill_index = reaction_to_skill_index(key)
if skill_index is not None:
return IncomingCallback(
user_id=sender,
platform=PLATFORM,
chat_id=chat_id,
action="toggle_skill",
payload={"event_id": event_id, "reaction": key, "skill_index": skill_index},
)
return None
def from_room_event(event: Any, room_id: str, chat_id: str) -> IncomingEvent | None:
body = (getattr(event, "body", None) or "").strip()
sender = getattr(event, "sender", "")
if body.startswith("!"):
return from_command(body, sender=sender, chat_id=chat_id)
return IncomingMessage(
user_id=sender,
platform=PLATFORM,
chat_id=chat_id,
text=body,
attachments=extract_attachments(event),
reply_to=getattr(event, "replyto_event_id", None),
)

View file

@ -0,0 +1,41 @@
from __future__ import annotations
from adapter.matrix.handlers.chat import (
handle_archive,
handle_list_chats,
make_handle_new_chat,
handle_rename,
)
from adapter.matrix.handlers.confirm import handle_cancel, handle_confirm
from adapter.matrix.handlers.settings import (
handle_settings,
handle_settings_connectors,
handle_settings_plan,
handle_settings_safety,
handle_settings_skills,
handle_settings_soul,
handle_settings_status,
handle_settings_whoami,
handle_toggle_skill,
)
from core.handler import EventDispatcher
from core.protocol import IncomingCallback, IncomingCommand
def register_matrix_handlers(dispatcher: EventDispatcher, client=None, store=None) -> None:
dispatcher.register(IncomingCommand, "new", make_handle_new_chat(client, store))
dispatcher.register(IncomingCommand, "chats", handle_list_chats)
dispatcher.register(IncomingCommand, "rename", handle_rename)
dispatcher.register(IncomingCommand, "archive", handle_archive)
dispatcher.register(IncomingCommand, "settings", handle_settings)
dispatcher.register(IncomingCommand, "settings_skills", handle_settings_skills)
dispatcher.register(IncomingCommand, "settings_connectors", handle_settings_connectors)
dispatcher.register(IncomingCommand, "settings_soul", handle_settings_soul)
dispatcher.register(IncomingCommand, "settings_safety", handle_settings_safety)
dispatcher.register(IncomingCommand, "settings_plan", handle_settings_plan)
dispatcher.register(IncomingCommand, "settings_status", handle_settings_status)
dispatcher.register(IncomingCommand, "settings_whoami", handle_settings_whoami)
dispatcher.register(IncomingCallback, "confirm", handle_confirm)
dispatcher.register(IncomingCallback, "cancel", handle_cancel)
dispatcher.register(IncomingCallback, "toggle_skill", handle_toggle_skill)

View file

@ -0,0 +1,34 @@
from __future__ import annotations
from typing import Any
from adapter.matrix.store import get_room_meta, set_room_meta
async def handle_invite(client: Any, room: Any, event: Any, platform, store, auth_mgr) -> None:
existing = await get_room_meta(store, room.room_id)
if existing is not None:
return
user = await platform.get_or_create_user(
external_id=getattr(event, "sender", ""),
platform="matrix",
display_name=getattr(room, "display_name", None),
)
await auth_mgr.confirm(getattr(event, "sender", ""))
await client.join(room.room_id)
await set_room_meta(
store,
room.room_id,
{
"room_type": "chat",
"chat_id": "C1",
"display_name": getattr(room, "display_name", room.room_id),
"matrix_user_id": getattr(event, "sender", user.external_id),
},
)
message = (
f"Привет, {user.display_name or user.external_id}! Пиши — я здесь.\n\n"
f"Команды: !new · !chats · !rename · !archive · !skills"
)
await client.room_send(room.room_id, "m.room.message", {"msgtype": "m.text", "body": message})

View file

@ -0,0 +1,107 @@
from __future__ import annotations
from typing import Any, Awaitable, Callable
from adapter.matrix.store import set_room_meta
from core.protocol import IncomingCommand, OutgoingMessage
async def _fallback_new_chat(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
if not await auth_mgr.is_authenticated(event.user_id):
return [OutgoingMessage(chat_id=event.chat_id, text="Введите !start чтобы начать.")]
name = " ".join(event.args).strip() if event.args else ""
chats = await chat_mgr.list_active(event.user_id)
chat_id = f"C{len(chats) + 1}"
ctx = await chat_mgr.get_or_create(
user_id=event.user_id,
chat_id=chat_id,
platform=event.platform,
surface_ref=event.chat_id,
name=name or None,
)
return [
OutgoingMessage(
chat_id=event.chat_id, text=f"Создан чат: {ctx.display_name} ({ctx.chat_id})"
)
]
def make_handle_new_chat(
client: Any | None,
store: Any | None,
) -> Callable[..., Awaitable[list]]:
async def handle_new_chat(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
if client is None or store is None:
return await _fallback_new_chat(event, auth_mgr, platform, chat_mgr, settings_mgr)
if not await auth_mgr.is_authenticated(event.user_id):
return [OutgoingMessage(chat_id=event.chat_id, text="Введите !start чтобы начать.")]
name = " ".join(event.args).strip() if event.args else ""
chats = await chat_mgr.list_active(event.user_id)
chat_id = f"C{len(chats) + 1}"
room_name = name or f"Чат {chat_id}"
response = await client.room_create(
name=room_name,
invite=[event.user_id],
is_direct=False,
)
room_id = getattr(response, "room_id", None)
if not room_id:
return [OutgoingMessage(chat_id=event.chat_id, text="Не удалось создать комнату.")]
await set_room_meta(
store,
room_id,
{
"room_type": "chat",
"chat_id": chat_id,
"display_name": room_name,
"matrix_user_id": event.user_id,
},
)
ctx = await chat_mgr.get_or_create(
user_id=event.user_id,
chat_id=chat_id,
platform=event.platform,
surface_ref=room_id,
name=room_name,
)
return [
OutgoingMessage(
chat_id=event.chat_id,
text=f"Создан чат: {ctx.display_name} ({ctx.chat_id})\nКомната: {room_id}",
)
]
return handle_new_chat
async def handle_list_chats(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
chats = await chat_mgr.list_active(event.user_id)
if not chats:
return [OutgoingMessage(chat_id=event.chat_id, text="Нет активных чатов.")]
lines = [f"{c.display_name} ({c.chat_id})" for c in chats]
return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))]
async def handle_rename(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
if not event.args:
return [OutgoingMessage(chat_id=event.chat_id, text="Укажите название: !rename Название")]
ctx = await chat_mgr.rename(event.chat_id, " ".join(event.args), user_id=event.user_id)
return [OutgoingMessage(chat_id=event.chat_id, text=f"Переименован в: {ctx.display_name}")]
async def handle_archive(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
await chat_mgr.archive(event.chat_id, user_id=event.user_id)
return [OutgoingMessage(chat_id=event.chat_id, text="Чат архивирован.")]

View file

@ -0,0 +1,19 @@
from __future__ import annotations
from core.protocol import IncomingCallback, OutgoingMessage
async def handle_confirm(
event: IncomingCallback, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
action_id = event.payload.get("action_id", "unknown")
return [
OutgoingMessage(chat_id=event.chat_id, text=f"Действие подтверждено (id: {action_id}).")
]
async def handle_cancel(
event: IncomingCallback, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
action_id = event.payload.get("action_id", "unknown")
return [OutgoingMessage(chat_id=event.chat_id, text=f"Действие отменено (id: {action_id}).")]

View file

@ -0,0 +1,145 @@
from __future__ import annotations
from adapter.matrix.reactions import build_skills_text
from core.protocol import IncomingCommand, OutgoingMessage, SettingsAction
def _render_mapping(title: str, data: dict | None) -> str:
data = data or {}
lines = [title]
if not data:
lines.append("Нет данных.")
else:
for key, value in data.items():
lines.append(f"{key}: {value}")
return "\n".join(lines)
def _parse_bool(value: str) -> bool:
return value.lower() in {"1", "true", "yes", "on", "enable", "enabled"}
async def handle_settings(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
return [
OutgoingMessage(
chat_id=event.chat_id,
text=(
"⚙️ Настройки Matrix\n"
"!skills\n"
"!connectors\n"
"!soul [field value]\n"
"!safety [trigger on|off]\n"
"!plan\n"
"!status\n"
"!whoami"
),
)
]
async def handle_settings_skills(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
settings = await settings_mgr.get(event.user_id)
return [OutgoingMessage(chat_id=event.chat_id, text=build_skills_text(settings))]
async def handle_settings_connectors(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
settings = await settings_mgr.get(event.user_id)
return [
OutgoingMessage(
chat_id=event.chat_id, text=_render_mapping("🔗 Коннекторы", settings.connectors)
)
]
async def handle_settings_soul(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
if len(event.args) >= 2:
field = event.args[0]
value = " ".join(event.args[1:])
await settings_mgr.apply(
event.user_id,
SettingsAction(action="set_soul", payload={"field": field, "value": value}),
)
return [
OutgoingMessage(chat_id=event.chat_id, text=f"Личность обновлена: {field} = {value}")
]
settings = await settings_mgr.get(event.user_id)
return [
OutgoingMessage(chat_id=event.chat_id, text=_render_mapping("🧠 Личность", settings.soul))
]
async def handle_settings_safety(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
if len(event.args) >= 2:
trigger = event.args[0]
enabled = _parse_bool(event.args[1])
await settings_mgr.apply(
event.user_id,
SettingsAction(action="set_safety", payload={"trigger": trigger, "enabled": enabled}),
)
state = "включена" if enabled else "выключена"
return [OutgoingMessage(chat_id=event.chat_id, text=f"Безопасность {trigger} {state}")]
settings = await settings_mgr.get(event.user_id)
return [
OutgoingMessage(
chat_id=event.chat_id, text=_render_mapping("🔒 Безопасность", settings.safety)
)
]
async def handle_settings_plan(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
settings = await settings_mgr.get(event.user_id)
return [OutgoingMessage(chat_id=event.chat_id, text=_render_mapping("💳 План", settings.plan))]
async def handle_settings_status(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
chats = await chat_mgr.list_active(event.user_id)
settings = await settings_mgr.get(event.user_id)
text = "\n".join(
[
"📊 Статус",
f"Активных чатов: {len(chats)}",
f"Скиллов: {len(settings.skills)}",
f"Коннекторов: {len(settings.connectors)}",
]
)
return [OutgoingMessage(chat_id=event.chat_id, text=text)]
async def handle_settings_whoami(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
return [OutgoingMessage(chat_id=event.chat_id, text=f"👤 {event.platform}:{event.user_id}")]
async def handle_toggle_skill(event, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
settings = await settings_mgr.get(event.user_id)
keys = list(settings.skills.keys())
skill = event.payload.get("skill")
if not skill:
idx = event.payload.get("skill_index")
if isinstance(idx, int) and 1 <= idx <= len(keys):
skill = keys[idx - 1]
if not skill:
return [OutgoingMessage(chat_id=event.chat_id, text="Ошибка: не удалось определить навык.")]
enabled = not bool(settings.skills.get(skill, False))
await settings_mgr.apply(
event.user_id,
SettingsAction(action="toggle_skill", payload={"skill": skill, "enabled": enabled}),
)
state = "включён" if enabled else "выключен"
return [OutgoingMessage(chat_id=event.chat_id, text=f"Навык {skill} {state}.")]

View file

@ -0,0 +1,68 @@
from __future__ import annotations
from typing import Any
from nio import AsyncClient
from sdk.interface import UserSettings
CONFIRM_REACTION = "👍"
CANCEL_REACTION = ""
SKILL_REACTIONS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"]
REACTION_TO_INDEX = {emoji: idx + 1 for idx, emoji in enumerate(SKILL_REACTIONS)}
def build_skills_text(settings: UserSettings) -> str:
lines: list[str] = ["🧩 Скиллы"]
for idx, (name, enabled) in enumerate(settings.skills.items(), start=1):
state = "" if enabled else ""
emoji = SKILL_REACTIONS[idx - 1] if idx - 1 < len(SKILL_REACTIONS) else f"{idx}."
lines.append(f"{state} {emoji} {name}")
lines.append("")
lines.append("Реакции 1⃣-9⃣ переключают навыки.")
return "\n".join(lines)
def build_confirmation_text(description: str) -> str:
return "\n".join(
[
"🤖 Lambda",
description,
"",
f"{CONFIRM_REACTION} подтвердить · {CANCEL_REACTION} отменить",
"!yes — подтвердить · !no — отменить",
]
)
def reaction_to_skill_index(key: str) -> int | None:
return REACTION_TO_INDEX.get(key)
async def add_reaction(client: AsyncClient, room_id: str, event_id: str, key: str) -> Any:
return await client.room_send(
room_id,
"m.reaction",
{
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": event_id,
"key": key,
}
},
)
async def remove_reaction(client: AsyncClient, room_id: str, event_id: str, key: str) -> Any:
return await client.room_send(
room_id,
"m.reaction",
{
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": event_id,
"key": key,
},
"undo": True,
},
)

View file

@ -0,0 +1,23 @@
from __future__ import annotations
from adapter.matrix.store import get_room_meta, next_chat_id, set_room_meta
from core.store import StateStore
async def resolve_chat_id(store: StateStore, room_id: str, matrix_user_id: str) -> str:
meta = await get_room_meta(store, room_id)
if meta and meta.get("chat_id"):
return meta["chat_id"]
chat_id = await next_chat_id(store, matrix_user_id)
await set_room_meta(
store,
room_id,
{
"room_type": "chat",
"chat_id": chat_id,
"display_name": f"Чат {chat_id}",
"matrix_user_id": matrix_user_id,
},
)
return chat_id

50
adapter/matrix/store.py Normal file
View file

@ -0,0 +1,50 @@
from __future__ import annotations
from core.store import StateStore
ROOM_META_PREFIX = "matrix_room:"
USER_META_PREFIX = "matrix_user:"
ROOM_STATE_PREFIX = "matrix_state:"
SKILLS_MSG_PREFIX = "matrix_skills_msg:"
async def get_room_meta(store: StateStore, room_id: str) -> dict | None:
return await store.get(f"{ROOM_META_PREFIX}{room_id}")
async def set_room_meta(store: StateStore, room_id: str, meta: dict) -> None:
await store.set(f"{ROOM_META_PREFIX}{room_id}", meta)
async def get_user_meta(store: StateStore, matrix_user_id: str) -> dict | None:
return await store.get(f"{USER_META_PREFIX}{matrix_user_id}")
async def set_user_meta(store: StateStore, matrix_user_id: str, meta: dict) -> None:
await store.set(f"{USER_META_PREFIX}{matrix_user_id}", meta)
async def get_room_state(store: StateStore, room_id: str) -> str:
data = await store.get(f"{ROOM_STATE_PREFIX}{room_id}")
return data["state"] if data else "idle"
async def set_room_state(store: StateStore, room_id: str, state: str) -> None:
await store.set(f"{ROOM_STATE_PREFIX}{room_id}", {"state": state})
async def get_skills_message_id(store: StateStore, room_id: str) -> str | None:
data = await store.get(f"{SKILLS_MSG_PREFIX}{room_id}")
return data["event_id"] if data else None
async def set_skills_message_id(store: StateStore, room_id: str, event_id: str) -> None:
await store.set(f"{SKILLS_MSG_PREFIX}{room_id}", {"event_id": event_id})
async def next_chat_id(store: StateStore, matrix_user_id: str) -> str:
meta = await get_user_meta(store, matrix_user_id) or {}
index = int(meta.get("next_chat_index", 1))
meta["next_chat_index"] = index + 1
await set_user_meta(store, matrix_user_id, meta)
return f"C{index}"

View file

@ -15,7 +15,7 @@ from core.protocol import (
OutgoingEvent,
)
from core.settings import SettingsManager
from platform.interface import PlatformClient
from sdk.interface import PlatformClient
logger = structlog.get_logger(__name__)

View file

@ -4,9 +4,19 @@ from __future__ import annotations
from core.protocol import IncomingCommand, OutgoingMessage
def _command(platform: str, name: str) -> str:
prefix = "!" if platform == "matrix" else "/"
return f"{prefix}{name}"
async def handle_new_chat(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
if not await auth_mgr.is_authenticated(event.user_id):
return [OutgoingMessage(chat_id=event.chat_id, text="Введите /start чтобы начать.")]
return [
OutgoingMessage(
chat_id=event.chat_id,
text=f"Введите {_command(event.platform, 'start')} чтобы начать.",
)
]
name = " ".join(event.args) if event.args else None
ctx = await chat_mgr.get_or_create(
user_id=event.user_id,
@ -20,7 +30,12 @@ async def handle_new_chat(event: IncomingCommand, auth_mgr, platform, chat_mgr,
async def handle_rename(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
if not event.args:
return [OutgoingMessage(chat_id=event.chat_id, text="Укажите название: /rename Название")]
return [
OutgoingMessage(
chat_id=event.chat_id,
text=f"Укажите название: {_command(event.platform, 'rename')} Название",
)
]
ctx = await chat_mgr.rename(event.chat_id, " ".join(event.args))
return [OutgoingMessage(chat_id=event.chat_id, text=f"Переименован в: {ctx.display_name}")]

View file

@ -4,9 +4,18 @@ from __future__ import annotations
from core.protocol import IncomingMessage, OutgoingMessage, OutgoingTyping
def _start_command(platform: str) -> str:
return "!start" if platform == "matrix" else "/start"
async def handle_message(event: IncomingMessage, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
if not await auth_mgr.is_authenticated(event.user_id):
return [OutgoingMessage(chat_id=event.chat_id, text="Введите /start чтобы начать.")]
return [
OutgoingMessage(
chat_id=event.chat_id,
text=f"Введите {_start_command(event.platform)} чтобы начать.",
)
]
# Voice slot fallback: audio attachment without registered voice_handler
if event.attachments and event.attachments[0].type == "audio":

View file

@ -5,7 +5,7 @@ import structlog
from core.protocol import SettingsAction
from core.store import StateStore
from platform.interface import PlatformClient, UserSettings
from sdk.interface import PlatformClient, UserSettings
logger = structlog.get_logger(__name__)

View file

@ -0,0 +1,172 @@
# Ресёрч: aiogram 3.x Architecture Review
> **Дата:** 2026-03-30
> **Вердикт:** APPROVED с двумя уточнениями
---
## 1. Структура проекта
**Официальный пример multi_file_bot:**
```
multi_file_bot/
bot.py
handlers/
common.py
...
```
**Best practice для средних проектов (наш случай):**
```
adapter/telegram/
bot.py ← Dispatcher + include_routers + polling/webhook
converter.py ← граница aiogram ↔ core/
states.py ← все StatesGroup
handlers/ ← по одному Router на модуль
keyboards/ ← InlineKeyboardBuilder фабрики
middleware.py ← DI + logging + rate limit
```
**Оценка:** наша структура соответствует стандарту. ✓
---
## 2. Middleware vs Converter
В aiogram 3.x эти два паттерна решают **разные задачи** и должны использоваться вместе.
| | Middleware | Converter |
|---|---|---|
| Назначение | Infrastructure | Бизнес-логика |
| Что делает | Логирование, DI, rate limit, сессия БД | aiogram Event → IncomingEvent |
| Когда вызывается | До и после хендлера | Внутри хендлера |
**Правильная комбинация:**
```python
# middleware.py — только infrastructure
class DependencyMiddleware(BaseMiddleware):
def __init__(self, platform, store):
self.platform = platform
self.store = store
async def __call__(self, handler, event, data):
data["platform"] = self.platform
data["store"] = self.store
return await handler(event, data)
# handler — converter вызывается внутри
async def handle_message(message: Message, platform, store):
event = to_incoming_message(message) # converter
results = await dispatcher.dispatch(event, platform, store)
await send_results(message, results) # converter обратно
```
**Оценка:** наш converter.py — правильный паттерн. Добавить `middleware.py` для DI. ✓+
---
## 3. Dependency Injection
Стандарт aiogram 3.x — **через middleware + data dict**:
```python
# Регистрация в bot.py
dp.message.middleware(DependencyMiddleware(platform=platform_client, store=store))
# Получение в handler (через type hint на имя ключа)
async def handle_message(message: Message, platform: PlatformClient, store: StateStore):
...
```
Альтернатива — через `dp["key"] = value` (Dispatcher workflow data):
```python
dp["platform"] = platform_client # в bot.py
async def handler(message: Message, platform: PlatformClient): # aiogram сам находит по типу
...
```
**Оценка:** нужно явно добавить один из этих механизмов, иначе хендлеры не получат platform/store. ⚠️
---
## 4. InlineKeyboardBuilder
`InlineKeyboardBuilder` — рекомендуемый подход в aiogram 3.x. `InlineKeyboardMarkup` с вложенными списками считается устаревшим стилем.
```python
# keyboards/chat.py
from aiogram.utils.keyboard import InlineKeyboardBuilder
def chats_keyboard(chats: list[ChatContext]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for chat in chats:
builder.button(text=f"💬 {chat.name}", callback_data=f"chat:{chat.chat_id}")
builder.button(text=" Новый чат", callback_data="new_chat")
builder.adjust(1) # одна кнопка в строку
return builder.as_markup()
```
**Оценка:** использовать `InlineKeyboardBuilder` везде. ✓
---
## 5. F-фильтры (MagicFilter)
aiogram 3.x MagicFilter (`F`) — стандарт вместо ручных проверок в хендлерах:
```python
from aiogram import F
# Вместо if message.text == "/start" внутри хендлера
router.message.register(start_handler, Command("start"))
# Фильтр по типу вложения
router.message.register(voice_handler, F.voice)
router.message.register(photo_handler, F.photo)
# Фильтр по состоянию
router.message.register(handle_name_input, OnboardingState.waiting_for_name)
# Callback фильтр
router.callback_query.register(confirm_handler, F.data.startswith("confirm:"))
```
**Оценка:** использовать F-фильтры при регистрации роутеров — чище, чем if/else в хендлерах. ✓
---
## 6. Сцены (Scenes) — новинка aiogram 3.x
aiogram 3.4+ ввёл `Scene` как улучшенный FSM для сложных диалогов:
```python
from aiogram.fsm.scene import Scene, on
class OnboardingScene(Scene, state="onboarding"):
@on.message.enter()
async def on_enter(self, message: Message):
await message.answer("Как зовут твоего агента?")
@on.message()
async def on_name(self, message: Message, state: FSMContext):
await state.update_data(agent_name=message.text)
await self.wizard.goto(OnboardingScene2)
```
**Оценка:** Scenes — опциональное улучшение для онбординга. Классический FSM через StatesGroup тоже корректен и проще для понимания. Использовать StatesGroup для прототипа, Scenes — в будущем. ✓
---
## Итог
| Решение | Статус |
|---|---|
| Router-based архитектура, один Router на модуль | ✅ Стандарт |
| converter.py как граница aiogram ↔ core/ | ✅ Правильный паттерн |
| InlineKeyboardBuilder в keyboards/ | ✅ Рекомендуется |
| SQLiteStorage для FSM | ✅ Стандарт для MVP |
| **Нужно добавить: DependencyMiddleware** | ⚠️ DI без него не работает |
| **Нужно добавить: F-фильтры при регистрации** | ⚠️ Иначе проверки в хендлерах |
**Архитектура одобрена.** Два уточнения (middleware.py и F-фильтры) небольшие и органично вписываются в текущую структуру.

View file

@ -0,0 +1,704 @@
# Forum Topics Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Добавить опциональный Forum Topics режим — пользователь подключает Telegram-супергруппу, его DM-чаты синхронизируются с нативными темами форума.
**Architecture:** Каждый `chat` в БД получает опциональный `forum_thread_id`. Адаптер маршрутизирует: пришло из DM → отвечает в DM с тегом, пришло из Forum-темы → отвечает в ту же тему без тега. Core не меняется — `chat_id` (UUID) одинаковый для обеих поверхностей.
**Tech Stack:** aiogram 3.x, SQLite (sqlite3), Python 3.11+
**Working directory:** `/Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram`
---
## File Map
| Файл | Действие | Что меняется |
|------|----------|--------------|
| `adapter/telegram/db.py` | Modify | Миграция схемы + 4 новых функции |
| `adapter/telegram/states.py` | Modify | Добавить `ForumSetupState` |
| `adapter/telegram/converter.py` | Modify | Добавить `is_forum_message`, `resolve_chat_id` |
| `adapter/telegram/handlers/forum.py` | Create | `/forum` команда + онбординг |
| `adapter/telegram/handlers/chat.py` | Modify | `cmd_new_chat` + `handle_message` с Forum-маршрутизацией |
| `adapter/telegram/bot.py` | Modify | Зарегистрировать `forum.router` |
| `tests/adapter/test_forum_db.py` | Create | Тесты новых функций БД |
---
## Task 1: DB migration + новые функции
**Files:**
- Modify: `adapter/telegram/db.py`
- Create: `tests/adapter/__init__.py`
- Create: `tests/adapter/test_forum_db.py`
- [ ] **Step 1: Создать тест-файл и написать падающие тесты**
```python
# tests/adapter/__init__.py
# (пустой файл)
```
```python
# tests/adapter/test_forum_db.py
from __future__ import annotations
import os
import tempfile
import pytest
os.environ["DB_PATH"] = ":memory:"
from adapter.telegram.db import (
init_db,
get_or_create_tg_user,
create_chat,
set_forum_group,
get_forum_group,
set_forum_thread,
get_chat_by_thread,
)
@pytest.fixture(autouse=True)
def fresh_db(tmp_path, monkeypatch):
db_file = str(tmp_path / "test.db")
monkeypatch.setenv("DB_PATH", db_file)
# reload module so DB_PATH is picked up
import importlib
import adapter.telegram.db as db_mod
importlib.reload(db_mod)
db_mod.init_db()
return db_mod
def test_set_and_get_forum_group(fresh_db):
db = fresh_db
db.get_or_create_tg_user(111, "usr-111", "Alice")
assert db.get_forum_group(111) is None
db.set_forum_group(111, 999888)
assert db.get_forum_group(111) == 999888
def test_set_forum_thread_and_get_by_thread(fresh_db):
db = fresh_db
db.get_or_create_tg_user(222, "usr-222", "Bob")
chat_id = db.create_chat(222, "Чат #1")
assert db.get_chat_by_thread(222, 42) is None
db.set_forum_thread(chat_id, 42)
chat = db.get_chat_by_thread(222, 42)
assert chat is not None
assert chat["chat_id"] == chat_id
assert chat["forum_thread_id"] == 42
def test_get_chat_by_thread_wrong_user(fresh_db):
db = fresh_db
db.get_or_create_tg_user(333, "usr-333", "Carol")
chat_id = db.create_chat(333, "Чат #1")
db.set_forum_thread(chat_id, 77)
assert db.get_chat_by_thread(999, 77) is None
```
- [ ] **Step 2: Запустить тесты — убедиться что падают**
```bash
cd /Users/a/MAI/sem2/lambda/surfaces-bot
PYTHONPATH=.worktrees/telegram pytest tests/adapter/test_forum_db.py -v
```
Ожидаем: `ImportError` — функции ещё не существуют.
- [ ] **Step 3: Добавить миграцию и новые функции в `db.py`**
В `init_db()` добавить после `CREATE TABLE IF NOT EXISTS chats`:
```python
def init_db() -> None:
with _conn() as con:
con.executescript("""
CREATE TABLE IF NOT EXISTS 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 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,
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
```
Добавить в конец файла:
```python
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
```
- [ ] **Step 4: Запустить тесты — убедиться что проходят**
```bash
cd /Users/a/MAI/sem2/lambda/surfaces-bot
PYTHONPATH=.worktrees/telegram pytest tests/adapter/test_forum_db.py -v
```
Ожидаем: `3 passed`.
- [ ] **Step 5: Убедиться что все тесты проекта не сломались**
```bash
cd /Users/a/MAI/sem2/lambda/surfaces-bot
PYTHONPATH=.worktrees/telegram pytest tests/ -v
```
Ожидаем: все тесты `passed`.
- [ ] **Step 6: Commit**
```bash
cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram
git add adapter/telegram/db.py ../../tests/adapter/
git commit -m "feat: db migration + forum_group_id/forum_thread_id functions"
```
---
## Task 2: ForumSetupState в states.py
**Files:**
- Modify: `adapter/telegram/states.py`
- [ ] **Step 1: Добавить ForumSetupState**
```python
# adapter/telegram/states.py
from aiogram.fsm.state import State, StatesGroup
class ChatState(StatesGroup):
idle = State()
waiting_response = State()
class SettingsState(StatesGroup):
menu = State()
soul_editing = State()
confirm_action = State()
class ForumSetupState(StatesGroup):
waiting_for_group = State() # ждём пересылку из группы
```
- [ ] **Step 2: Проверить синтаксис**
```bash
cd /Users/a/MAI/sem2/lambda/surfaces-bot
uv run python -m py_compile .worktrees/telegram/adapter/telegram/states.py && echo OK
```
Ожидаем: `OK`.
- [ ] **Step 3: Commit**
```bash
cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram
git add adapter/telegram/states.py
git commit -m "feat: add ForumSetupState"
```
---
## Task 3: converter.py — is_forum_message и resolve_chat_id
**Files:**
- Modify: `adapter/telegram/converter.py`
- [ ] **Step 1: Добавить функции в converter.py**
Добавить в конец файла (после `format_outgoing`):
```python
def is_forum_message(message: Message) -> bool:
"""Сообщение пришло из Forum-темы (не из General и не из DM)."""
return (
message.message_thread_id is not None
and message.chat.type in ("supergroup", "group")
)
def resolve_forum_chat_id(message: Message) -> str | None:
"""
Для Forum-сообщения ищет chat_id (UUID) по forum_thread_id в БД.
Возвращает None если тема не зарегистрирована.
"""
from adapter.telegram import db
tg_user_id = message.from_user.id
thread_id = message.message_thread_id
chat = db.get_chat_by_thread(tg_user_id, thread_id)
return chat["chat_id"] if chat else None
```
- [ ] **Step 2: Проверить синтаксис**
```bash
cd /Users/a/MAI/sem2/lambda/surfaces-bot
uv run python -m py_compile .worktrees/telegram/adapter/telegram/converter.py && echo OK
```
Ожидаем: `OK`.
- [ ] **Step 3: Commit**
```bash
cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram
git add adapter/telegram/converter.py
git commit -m "feat: add is_forum_message and resolve_forum_chat_id to converter"
```
---
## Task 4: handlers/forum.py — /forum и онбординг
**Files:**
- Create: `adapter/telegram/handlers/forum.py`
- [ ] **Step 1: Создать handlers/forum.py**
```python
# adapter/telegram/handlers/forum.py
from __future__ import annotations
from aiogram import Bot, F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import Message
from adapter.telegram import db
from adapter.telegram.states import ChatState, ForumSetupState
router = Router(name="forum")
async def _check_forum_admin(bot: Bot, group_id: int) -> bool:
"""Проверяет что бот — администратор с правом управления темами."""
try:
me = await bot.get_me()
member = await bot.get_chat_member(group_id, me.id)
return (
member.status in ("administrator", "creator")
and getattr(member, "can_manage_topics", False)
)
except Exception:
return False
@router.message(Command("forum"))
async def cmd_forum(message: Message, state: FSMContext) -> None:
await state.set_state(ForumSetupState.waiting_for_group)
await message.answer(
"📋 Подключение Forum-группы\n\n"
"1. Создай супергруппу в Telegram\n"
"2. Включи Topics: настройки группы → Topics\n"
"3. Добавь меня как администратора с правом управления темами\n"
"4. Перешли мне любое сообщение из этой группы\n\n"
"Или /cancel чтобы отменить."
)
@router.message(ForumSetupState.waiting_for_group, Command("cancel"))
async def cmd_cancel_forum(message: Message, state: FSMContext) -> None:
await state.set_state(ChatState.idle)
await message.answer("❌ Настройка форума отменена.")
@router.message(ForumSetupState.waiting_for_group, F.forward_from_chat)
async def handle_group_forward(
message: Message,
state: FSMContext,
) -> None:
group = message.forward_from_chat
if group.type != "supergroup":
await message.answer(
"⚠️ Это не супергруппа. Нужна именно супергруппа с включёнными Topics."
)
return
group_id = group.id
if not await _check_forum_admin(message.bot, group_id):
await message.answer(
"⚠️ Не могу управлять темами в этой группе.\n\n"
"Убедись что:\n"
"• Я добавлен как администратор\n"
"• У меня есть право «Управление темами»"
)
return
tg_id = message.from_user.id
db.set_forum_group(tg_id, group_id)
# Создать Forum-темы для всех существующих активных DM-чатов
chats = db.get_user_chats(tg_id)
created = 0
for chat in chats:
if chat.get("forum_thread_id"):
continue # уже есть тема
try:
topic = await message.bot.create_forum_topic(
chat_id=group_id,
name=chat["name"],
)
db.set_forum_thread(chat["chat_id"], topic.message_thread_id)
created += 1
except Exception:
pass # не страшно — тему можно создать позже через /new
await state.set_state(ChatState.idle)
await message.answer(
f"✅ Группа «{group.title}» подключена!\n"
f"Создано тем в форуме: {created} из {len(chats)}.\n\n"
"Теперь можешь писать как в DM, так и в темах форума."
)
@router.message(ForumSetupState.waiting_for_group)
async def handle_forward_wrong(message: Message) -> None:
await message.answer(
"Жду пересланное сообщение из группы. "
"Перешли любое сообщение из своей супергруппы."
)
```
- [ ] **Step 2: Проверить синтаксис**
```bash
cd /Users/a/MAI/sem2/lambda/surfaces-bot
uv run python -m py_compile .worktrees/telegram/adapter/telegram/handlers/forum.py && echo OK
```
Ожидаем: `OK`.
- [ ] **Step 3: Commit**
```bash
cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram
git add adapter/telegram/handlers/forum.py
git commit -m "feat: add handlers/forum.py — /forum onboarding flow"
```
---
## Task 5: handlers/chat.py — Forum-маршрутизация
**Files:**
- Modify: `adapter/telegram/handlers/chat.py`
- [ ] **Step 1: Обновить импорты в chat.py**
Заменить блок импортов целиком:
```python
# adapter/telegram/handlers/chat.py
from __future__ import annotations
import asyncio
from aiogram import F, Router
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,
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
router = Router(name="chat")
```
- [ ] **Step 2: Обновить `_send_outgoing` — добавить Forum-вариант**
Заменить функцию `_send_outgoing`:
```python
async def _send_outgoing(
message: Message,
chat_name: str,
events: list,
forum_group_id: int | None = None,
forum_thread_id: int | None = None,
) -> None:
for event in events:
if forum_group_id and forum_thread_id:
# Ответ в Forum-тему (без тега)
text = event.text if isinstance(event, (OutgoingMessage, OutgoingUI)) else str(event)
if isinstance(event, OutgoingUI) and event.buttons:
action_id = event.buttons[0].payload.get("action_id", "unknown")
kb = confirm_keyboard(action_id)
await message.bot.send_message(
forum_group_id, text,
message_thread_id=forum_thread_id,
reply_markup=kb,
)
else:
await message.bot.send_message(
forum_group_id, text,
message_thread_id=forum_thread_id,
)
else:
# Ответ в DM с тегом
if isinstance(event, OutgoingUI) and event.buttons:
action_id = event.buttons[0].payload.get("action_id", "unknown")
kb = confirm_keyboard(action_id)
await message.answer(format_outgoing(chat_name, event), reply_markup=kb)
elif isinstance(event, (OutgoingMessage, OutgoingUI)):
await message.answer(format_outgoing(chat_name, event))
```
- [ ] **Step 3: Обновить `handle_message` — Forum-маршрутизация**
Заменить функцию `handle_message`:
```python
@router.message(ChatState.idle, (F.text | F.photo | F.document | F.voice) & ~F.text.startswith("/"))
async def handle_message(
message: Message,
state: FSMContext,
dispatcher: EventDispatcher,
) -> None:
tg_id = message.from_user.id
# Определяем chat_id и канал ответа
if is_forum_message(message):
chat_id = resolve_forum_chat_id(message)
if not chat_id:
await message.reply(
"Эта тема не зарегистрирована как чат. "
"Введи /new в этой теме чтобы создать чат."
)
return
chat = db.get_chat_by_thread(tg_id, message.message_thread_id)
chat_name = chat["name"]
forum_group_id = message.chat.id
forum_thread_id = message.message_thread_id
else:
data = await state.get_data()
chat_id = data.get("active_chat_id")
chat_name = data.get("active_chat_name", "Чат")
forum_group_id = None
forum_thread_id = None
if not chat_id:
await message.answer("Нет активного чата. Введите /start")
return
await state.set_state(ChatState.waiting_response)
async def _typing_loop():
while True:
await message.bot.send_chat_action(message.chat.id, "typing")
await asyncio.sleep(4)
task = asyncio.create_task(_typing_loop())
try:
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)
finally:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
await state.set_state(ChatState.idle)
await _send_outgoing(message, chat_name, events, forum_group_id, forum_thread_id)
```
- [ ] **Step 4: Обновить `cmd_new_chat` — ветвление DM vs Forum**
Заменить функцию `cmd_new_chat`:
```python
@router.message(Command("new"))
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
if is_forum_message(message):
# /new в Forum-теме — регистрируем эту тему как чат
thread_id = message.message_thread_id
existing = db.get_chat_by_thread(tg_id, thread_id)
if existing:
await message.reply(f"Эта тема уже зарегистрирована как [{existing['name']}].")
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 message.reply(f"✅ [{chat_name}] зарегистрирован. Пиши здесь!")
else:
# /new в DM
count = db.count_chats(tg_id)
chat_name = name or f"Чат #{count + 1}"
chat_id = db.create_chat(tg_id, chat_name)
# Если есть форум-группа — создать тему и там
group_id = db.get_forum_group(tg_id)
if group_id:
try:
topic = await message.bot.create_forum_topic(
chat_id=group_id,
name=chat_name,
)
db.set_forum_thread(chat_id, topic.message_thread_id)
except Exception:
pass # не блокирует создание DM-чата
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}] создан. Пиши!")
```
- [ ] **Step 5: Проверить синтаксис**
```bash
cd /Users/a/MAI/sem2/lambda/surfaces-bot
uv run python -m py_compile .worktrees/telegram/adapter/telegram/handlers/chat.py && echo OK
```
Ожидаем: `OK`.
- [ ] **Step 6: Запустить все тесты**
```bash
cd /Users/a/MAI/sem2/lambda/surfaces-bot
PYTHONPATH=.worktrees/telegram pytest tests/ -v
```
Ожидаем: все тесты `passed`.
- [ ] **Step 7: Commit**
```bash
cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram
git add adapter/telegram/handlers/chat.py
git commit -m "feat: forum routing in handle_message and cmd_new_chat"
```
---
## Task 6: bot.py — регистрация forum.router
**Files:**
- Modify: `adapter/telegram/bot.py`
- [ ] **Step 1: Добавить импорт и регистрацию router**
В блоке импортов добавить:
```python
from adapter.telegram.handlers import auth, chat, confirm, forum, settings
```
В `main()` после `dp.include_router(auth.router)`:
```python
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)
```
- [ ] **Step 2: Проверить синтаксис**
```bash
cd /Users/a/MAI/sem2/lambda/surfaces-bot
uv run python -m py_compile .worktrees/telegram/adapter/telegram/bot.py && echo OK
```
Ожидаем: `OK`.
- [ ] **Step 3: Финальный прогон всех тестов**
```bash
cd /Users/a/MAI/sem2/lambda/surfaces-bot
PYTHONPATH=.worktrees/telegram pytest tests/ -v
```
Ожидаем: все тесты `passed`.
- [ ] **Step 4: Commit**
```bash
cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram
git add adapter/telegram/bot.py
git commit -m "feat: register forum router in bot.py"
```

View file

@ -0,0 +1,180 @@
# Forum Topics Mode Design
**Date:** 2026-03-31
**Status:** Approved — ready for implementation
**Scope:** `adapter/telegram/` — расширение существующего адаптера
---
## Контекст
Forum Topics — опциональный advanced-режим поверх существующих виртуальных DM-чатов.
Пользователь подключает свою Telegram-супергруппу с Topics — и его чаты появляются
как нативные темы Telegram. DM и Forum работают **одновременно**: один контекст,
две поверхности.
---
## Принцип работы
Каждый чат (`chat_id` = UUID) получает опциональный `forum_thread_id`.
- Пользователь пишет в DM → бот отвечает в DM с тегом `[Чат #N]`
- Пользователь пишет в Forum-тему → бот отвечает в ту же тему (без тега)
- Контекст (`chat_id`) один и тот же — платформа видит единый разговор
---
## БД — изменения схемы
```sql
ALTER TABLE tg_users ADD COLUMN forum_group_id INTEGER;
ALTER TABLE chats ADD COLUMN forum_thread_id INTEGER;
```
`forum_group_id` — ID супергруппы пользователя (NULL если группа не подключена).
`forum_thread_id` — ID темы в форуме (NULL если чат создан только в DM).
Новые функции в `db.py`:
```python
def set_forum_group(tg_user_id: int, group_id: int) -> None
def get_forum_group(tg_user_id: int) -> int | None
def set_forum_thread(chat_id: str, thread_id: int) -> None
def get_chat_by_thread(tg_user_id: int, thread_id: int) -> dict | None
```
---
## Онбординг — `/forum`
### FSM
```python
class ForumSetupState(StatesGroup):
waiting_for_group = State() # ждём пересылку из группы
```
### Флоу
```
/forum
→ FSM: ForumSetupState.waiting_for_group
→ "Создай супергруппу, включи Topics, добавь меня администратором
с правом управления темами. Затем перешли мне любое сообщение из группы."
[пользователь пересылает сообщение]
→ Проверить: forward_from_chat.type == "supergroup"
→ Проверить права бота (администратор + can_manage_topics)
❌ нет прав → объяснить что именно не так, остаться в состоянии
→ Сохранить forum_group_id в БД
→ Создать Forum-тему для каждого существующего активного DM-чата
→ Записать forum_thread_id для каждого чата
→ Ответить в DM: "✅ Группа подключена! Твои чаты теперь доступны в Forum-темах."
→ FSM: clear
```
### Проверка прав
```python
async def check_forum_admin(bot: Bot, group_id: int) -> bool:
member = await bot.get_chat_member(group_id, (await bot.get_me()).id)
return (
member.status in ("administrator", "creator")
and getattr(member, "can_manage_topics", False)
)
```
---
## Создание чатов — синхронизация
### `/new` в DM (группа подключена)
1. Создать UUID-запись в `chats` (как сейчас)
2. `create_forum_topic(bot, group_id, chat_name)` → получить `thread_id`
3. Записать `forum_thread_id` в БД
4. Переключить FSM на новый чат
5. Ответить в DM: `"✅ [chat_name] создан."`
### `/new` в DM (группа НЕ подключена)
Без изменений — только DM-чат.
### `/new` в Forum-теме
1. Определить `thread_id` из `message.message_thread_id`
2. Создать UUID-запись в `chats` с `forum_thread_id = thread_id`
3. Название: из аргумента `/new Название` или из названия темы (`message.chat.forum_topic_created.name` при создании — иначе запросить у Telegram)
4. Ответить в теме: `"✅ Чат зарегистрирован. Пиши здесь!"`
---
## Маршрутизация сообщений
### Определение источника
```python
def is_forum_message(message: Message) -> bool:
return message.message_thread_id is not None
def resolve_chat_id(message: Message, tg_user_id: int) -> str | None:
if is_forum_message(message):
chat = db.get_chat_by_thread(tg_user_id, message.message_thread_id)
return chat["chat_id"] if chat else None
else:
# DM — берём active_chat_id из FSM StateData (как сейчас)
return None # caller reads from FSM
```
### Ответ
- Пришло из DM → `bot.send_message(tg_user_id, f"[{chat_name}] {text}")`
- Пришло из Forum-темы → `bot.send_message(group_id, text, message_thread_id=thread_id)`
В Forum-теме тег `[Чат #N]` **не нужен** — тема сама является визуальным разделителем.
---
## Обработчики — изменения
### `handlers/forum.py` (новый файл)
```python
router = Router(name="forum")
@router.message(Command("forum"))
async def cmd_forum(message, state): ... # запускает онбординг
@router.message(ForumSetupState.waiting_for_group, F.forward_from_chat)
async def handle_group_forward(message, state, dispatcher): ... # регистрирует группу
```
### `handlers/chat.py` — изменения
- `handle_message`: если `is_forum_message` → брать `chat_id` из БД по `thread_id`, отвечать в тему
- `cmd_new_chat`: ветвление по источнику (DM vs Forum) и наличию `forum_group_id`
### `states.py` — добавить
```python
class ForumSetupState(StatesGroup):
waiting_for_group = State()
```
---
## Что НЕ реализуем
- Отслеживание создания тем пользователем без `/new` — Telegram не присылает событие создания темы в боте
- Синхронизация удаления темы ↔ архивация DM-чата (только через команды)
- Поддержка нескольких групп на одного пользователя
---
## Порядок реализации
1. `db.py` — миграция + 4 новых функции
2. `states.py``ForumSetupState`
3. `handlers/forum.py``/forum` + onboarding
4. `handlers/chat.py``cmd_new_chat` с ветвлением, `handle_message` с Forum-маршрутизацией
5. `converter.py``is_forum_message`, `resolve_chat_id`

View file

@ -0,0 +1,283 @@
# Matrix Adapter Design
**Date:** 2026-03-31
**Status:** Approved — ready for implementation
**Scope:** `adapter/matrix/`
---
## Контекст
Matrix-адаптер — внутренняя поверхность для команды Lambda Lab: разработчики, тестировщики, авторы скиллов. UX ориентирован на удобство работы, не на онбординг.
Адаптер конвертирует matrix-nio события в `IncomingEvent` (core protocol) и отправляет `OutgoingEvent` обратно. Бизнес-логика — в `core/`, адаптер только переводит форматы и управляет Matrix API.
Клиент: Element (web/desktop). Стек: matrix-nio (async), Python 3.11+, SQLite.
---
## Онбординг — DM как первый чат (ленивый Space)
**Решение:** DM-комната с ботом = Чат #1. Space создаётся только при первом `!new`.
### Флоу — новый пользователь
1. Пользователь инвайтит бота в личные сообщения
2. Бот принимает инвайт, вызывает `platform.get_or_create_user(matrix_user_id, "matrix", display_name)`
3. Бот регистрирует DM-комнату как `chat_room` с `chat_id = C1` в SQLite
4. Бот пишет приветствие в DM — пользователь сразу пишет
5. При первом `!new` — бот создаёт Space `Lambda — {display_name}`, добавляет DM-комнату в Space через `m.space.child`, создаёт новую комнату-чат
### Флоу — возвращающийся пользователь
Если `matrix_user_id` уже есть в БД (бот перезапустился, или пользователь пишет повторно) — `get_or_create_user` возвращает `is_new=False`. Бот не создаёт ничего заново, просто обрабатывает сообщение в контексте существующей комнаты.
### Почему не Space сразу
Создание Space при инвайте порождает 3 инвайта подряд (Space + Settings + Чат 1) до первого сообщения. DM-first убирает этот шум, сохраняя такой же UX как Telegram.
### Приветствие
```
Привет, {display_name}! Пиши — я здесь.
Команды: !new · !chats · !rename · !archive · !skills
```
---
## Архитектура — Room-type routing
При получении события адаптер сначала определяет тип комнаты (`chat` / `settings`), затем маршрутизирует в соответствующий обработчик.
```
adapter/matrix/
bot.py — matrix-nio клиент, sync loop
converter.py — RoomEvent → IncomingEvent, OutgoingEvent → Matrix API
room_router.py — определяет тип комнаты: chat | settings
states.py — FSM состояния (per room_id, SQLite)
handlers/
auth.py — invite → onboarding
chat.py — сообщения, !new, !chats, !rename, !archive
settings.py — !skills, !connectors, !soul, !safety, !plan, !status, !whoami
confirm.py — реакции 👍/❌ и команды !yes / !no
reactions.py — helpers: add_reaction, remove_reactions, parse_reaction_event
```
---
## FSM состояния (per room_id)
```python
class RoomState(StatesGroup):
idle = State() # ждём сообщения
waiting_response = State() # запрос ушёл на платформу
confirm_pending = State() # ждём !yes/!no или реакцию 👍/❌
settings_active = State() # Settings-комната (не чат)
```
`room_type` хранится в SQLite. `room_router.py` читает его при каждом событии.
---
## Команды
Все команды на английском. Работают в любой комнате Space.
| Команда | Действие |
|---------|---------|
| `!new [name]` | Создать чат. При первом вызове — создаёт Space, переносит DM |
| `!chats` | Список чатов с текущим активным |
| `!rename <name>` | Переименовать текущую комнату |
| `!archive` | Вывести комнату из Space (не удалять) |
| `!skills` | Список скиллов — реакции как тумблеры |
| `!connectors` | Коннекторы (OAuth заглушки) |
| `!soul` | Личность агента |
| `!safety` | Настройки безопасности |
| `!plan` | Подписка и токены |
| `!status` | Состояние платформы и чатов |
| `!whoami` | Текущий аккаунт |
| `!yes` / `!no` | Подтверждение / отмена действия агента |
---
## Settings room
Создаётся при первом `!new` вместе со Space. Закреплена вверху Space.
### Скиллы — реакции как тумблеры
`!skills` → бот отправляет список. Каждый скилл пронумерован. Реакция 1N⃣ переключает соответствующий скилл (toggle). Несколько скиллов могут быть включены одновременно. Бот редактирует своё сообщение через `m.replace` после каждого переключения.
```
✅ 1 web-search — поиск в интернете
✅ 2 fetch-url — чтение веб-страниц
✅ 3 email — чтение почты
❌ 4 browser — управление браузером
❌ 5 image-gen — генерация изображений
✅ 6 files — работа с файлами
Реакция 16⃣ = переключить скилл
```
### Остальные настройки
`!connectors`, `!soul`, `!safety`, `!plan`, `!status`, `!whoami` — текстовые ответы, без интерактивных элементов. Поля задаются аргументами команды: `!soul name Lambda`, `!soul style brief`, `!safety on email-send`.
---
## Подтверждение действий агента
Агент запрашивает подтверждение → бот отправляет сообщение с описанием действия. Пользователь подтверждает **реакцией или командой**оба способа работают.
```
🤖 Lambda:
Отправить письмо azamat@lambda.lab?
Тема: «Отчёт за неделю»
👍 подтвердить · ❌ отменить
!yes — подтвердить · !no — отменить
Истекает через 5 минут
```
После ответа: бот убирает реакции с сообщения, редактирует статус (`m.replace`), переходит в `idle`.
FSM: `waiting_response``confirm_pending``idle`
---
## Долгие задачи — треды
Если задача занимает больше одного хода — бот создаёт тред от своего первого сообщения.
```
🤖 Lambda (основной поток):
Начинаю исследование «AI агенты 2025» — займёт 2-3 минуты.
🧵 Прогресс (в треде):
└── ✅ Ищу источники... (12 найдено)
└── ✅ Анализирую статьи...
└── ⏳ Формирую отчёт...
└── ○ Финальная проверка
```
Основной поток не засоряется. Финальный результат — отдельным сообщением в основной поток.
---
## Typing indicator
`m.typing` — отправлять перед запросом к платформе. Если запрос > 5 сек — возобновлять каждые 25 сек (Matrix typing живёт ~30 сек).
---
## Converter
`adapter/matrix/converter.py` — конвертация в обе стороны.
### matrix-nio → IncomingEvent
```python
def from_room_message(event: RoomMessageText, room_id: str, chat_id: str) -> IncomingMessage:
return IncomingMessage(
user_id=event.sender, # @user:matrix.org
platform="matrix",
chat_id=chat_id, # C1, C2... из rooms таблицы
text=event.body,
attachments=extract_attachments(event),
reply_to=event.replyto_event_id,
)
def extract_attachments(event: RoomMessageText) -> list[Attachment]:
# m.image → Attachment(type="image", url=mxc_url, mime_type=...)
# m.file → Attachment(type="document", url=mxc_url, filename=..., mime_type=...)
# m.audio → Attachment(type="audio", url=mxc_url, mime_type=...)
# m.text → []
msgtype = getattr(event, "msgtype", "m.text")
if msgtype == "m.image":
return [Attachment(type="image", url=event.url, mime_type=event.mimetype)]
elif msgtype == "m.file":
return [Attachment(type="document", url=event.url,
filename=event.body, mime_type=event.mimetype)]
elif msgtype == "m.audio":
return [Attachment(type="audio", url=event.url, mime_type=event.mimetype)]
return []
def from_reaction(event: ReactionEvent, room_id: str) -> IncomingCallback | None:
# Парсит m.reaction → IncomingCallback(action="toggle_skill" | "confirm" | "cancel")
...
def from_command(body: str, sender: str, room_id: str, chat_id: str) -> IncomingCommand | None:
# Парсит !new, !skills, !yes, !no и т.д. → IncomingCommand
...
```
### OutgoingEvent → Matrix
```python
async def send_outgoing(client: AsyncClient, room_id: str, event: OutgoingEvent) -> None:
if isinstance(event, OutgoingMessage):
await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": event.text})
elif isinstance(event, OutgoingUI):
# Confirmation request — текст + подсказка по реакциям/командам
body = f"{event.text}\n\n👍 подтвердить · ❌ отменить\n!yes — подтвердить · !no — отменить"
await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body})
await client.room_send(room_id, "m.reaction", {...}) # добавить 👍 и ❌ на сообщение
elif isinstance(event, OutgoingTyping):
await client.room_typing(room_id, event.is_typing, timeout=25000)
```
---
## БД схема
```sql
CREATE TABLE matrix_users (
matrix_user_id TEXT PRIMARY KEY, -- @user:matrix.org
platform_user_id TEXT NOT NULL, -- из MockPlatformClient
display_name TEXT,
space_id TEXT, -- NULL до первого !new
settings_room_id TEXT, -- NULL до первого !new
created_at TIMESTAMP
);
CREATE TABLE rooms (
room_id TEXT PRIMARY KEY, -- room_id Matrix
matrix_user_id TEXT NOT NULL,
room_type TEXT NOT NULL, -- 'chat' | 'settings'
chat_id TEXT, -- C1, C2... (NULL для settings)
display_name TEXT,
created_at TIMESTAMP,
archived_at TIMESTAMP,
FOREIGN KEY(matrix_user_id) REFERENCES matrix_users(matrix_user_id)
);
```
`StateStore` из `core/store.py` (`SQLiteStore`) — для FSM per room_id.
---
## Что НЕ реализуем в прототипе
- Webhook от платформы (используем sync `send_message`)
- E2E encryption (nio поддерживает, но усложняет прототип)
- Экспорт истории
- `!rename`, `!archive` — добавить после основного флоу
---
## Порядок реализации
1. `bot.py` — AsyncClient, sync loop, middleware для platform client
2. `states.py` — RoomState
3. `room_router.py` — определение типа комнаты
4. `converter.py` — from_room_message, from_reaction, from_command
5. `handlers/auth.py` — invite → onboarding
6. `handlers/chat.py` — сообщения + !new + !chats
7. `reactions.py` — helpers для работы с реакциями
8. `handlers/confirm.py` — реакции 👍/❌ + !yes/!no
9. `handlers/settings.py` — !skills с m.replace + остальные команды

View file

@ -9,7 +9,7 @@ from typing import Any, AsyncIterator, Literal
import structlog
from platform.interface import (
from sdk.interface import (
AgentEvent,
Attachment,
MessageChunk,

View file

View file

@ -0,0 +1 @@
from __future__ import annotations

View file

@ -0,0 +1,109 @@
from __future__ import annotations
from types import SimpleNamespace
from adapter.matrix.converter import from_command, from_reaction, from_room_event
from core.protocol import IncomingCallback, IncomingCommand, IncomingMessage
def text_event(body: str, sender: str = "@a:m.org", event_id: str = "$e1"):
return SimpleNamespace(
sender=sender, body=body, event_id=event_id, msgtype="m.text", replyto_event_id=None
)
def file_event(url: str = "mxc://x/y", filename: str = "doc.pdf", mime: str = "application/pdf"):
return SimpleNamespace(
sender="@a:m.org",
body=filename,
event_id="$e2",
msgtype="m.file",
replyto_event_id=None,
url=url,
mimetype=mime,
)
def image_event(url: str = "mxc://x/img", mime: str = "image/jpeg"):
return SimpleNamespace(
sender="@a:m.org",
body="img.jpg",
event_id="$e3",
msgtype="m.image",
replyto_event_id=None,
url=url,
mimetype=mime,
)
def reaction_event(key: str, relates_to: str = "$orig"):
return SimpleNamespace(
sender="@a:m.org",
event_id="$r1",
key=key,
content={"m.relates_to": {"key": key, "event_id": relates_to}},
)
async def test_plain_text_to_incoming_message():
result = from_room_event(text_event("Hello"), room_id="!r:m.org", chat_id="C1")
assert isinstance(result, IncomingMessage)
assert result.text == "Hello"
assert result.platform == "matrix"
assert result.chat_id == "C1"
assert result.attachments == []
async def test_bang_command_to_incoming_command():
result = from_room_event(text_event("!new Analysis"), room_id="!r:m.org", chat_id="C1")
assert isinstance(result, IncomingCommand)
assert result.command == "new"
assert result.args == ["Analysis"]
async def test_skills_alias_to_settings_command():
result = from_command("!skills", sender="@a:m.org", chat_id="C1")
assert isinstance(result, IncomingCommand)
assert result.command == "settings_skills"
async def test_yes_to_callback():
result = from_room_event(text_event("!yes"), room_id="!r:m.org", chat_id="C1")
assert isinstance(result, IncomingCallback)
assert result.action == "confirm"
async def test_no_to_callback():
result = from_room_event(text_event("!no"), room_id="!r:m.org", chat_id="C1")
assert isinstance(result, IncomingCallback)
assert result.action == "cancel"
async def test_file_attachment():
result = from_room_event(file_event(), room_id="!r:m.org", chat_id="C1")
assert isinstance(result, IncomingMessage)
assert len(result.attachments) == 1
a = result.attachments[0]
assert a.type == "document"
assert a.url == "mxc://x/y"
assert a.filename == "doc.pdf"
assert a.mime_type == "application/pdf"
async def test_image_attachment():
result = from_room_event(image_event(), room_id="!r:m.org", chat_id="C1")
assert result.attachments[0].type == "image"
assert result.attachments[0].mime_type == "image/jpeg"
async def test_reaction_confirm():
result = from_reaction(reaction_event("👍"), sender="@a:m.org", chat_id="C1")
assert isinstance(result, IncomingCallback)
assert result.action == "confirm"
async def test_reaction_toggle_skill():
result = from_reaction(reaction_event("2"), sender="@a:m.org", chat_id="C1")
assert isinstance(result, IncomingCallback)
assert result.action == "toggle_skill"
assert result.payload["skill_index"] == 2

View file

@ -0,0 +1,116 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
from adapter.matrix.bot import MatrixBot, build_runtime
from adapter.matrix.handlers.auth import handle_invite
from adapter.matrix.store import get_room_meta
from core.protocol import IncomingCallback, IncomingCommand, OutgoingMessage
from sdk.mock import MockPlatformClient
async def test_matrix_dispatcher_registers_custom_handlers():
runtime = build_runtime(platform=MockPlatformClient())
start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start")
await runtime.dispatcher.dispatch(start)
new = IncomingCommand(
user_id="u1", platform="matrix", chat_id="C1", command="new", args=["Research"]
)
result = await runtime.dispatcher.dispatch(new)
assert any(isinstance(r, OutgoingMessage) and "Research" in r.text for r in result)
chats = await runtime.chat_mgr.list_active("u1")
assert [c.chat_id for c in chats] == ["C1"]
new2 = IncomingCommand(
user_id="u1", platform="matrix", chat_id="C1", command="new", args=["Ops"]
)
await runtime.dispatcher.dispatch(new2)
chats = await runtime.chat_mgr.list_active("u1")
assert [c.chat_id for c in chats] == ["C1", "C2"]
skills = IncomingCommand(
user_id="u1", platform="matrix", chat_id="C1", command="settings_skills"
)
result = await runtime.dispatcher.dispatch(skills)
assert any(isinstance(r, OutgoingMessage) and "Реакции 1⃣-9" in r.text for r in result)
toggle = IncomingCallback(
user_id="u1",
platform="matrix",
chat_id="C1",
action="toggle_skill",
payload={"skill_index": 2},
)
result = await runtime.dispatcher.dispatch(toggle)
assert any(isinstance(r, OutgoingMessage) and "fetch-url" in r.text for r in result)
async def test_new_chat_creates_real_matrix_room_when_client_available():
client = SimpleNamespace(room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r2:example")))
runtime = build_runtime(platform=MockPlatformClient(), client=client)
start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start")
await runtime.dispatcher.dispatch(start)
new = IncomingCommand(
user_id="u1",
platform="matrix",
chat_id="C1",
command="new",
args=["Research"],
)
result = await runtime.dispatcher.dispatch(new)
client.room_create.assert_awaited_once_with(name="Research", invite=["u1"], is_direct=False)
chats = await runtime.chat_mgr.list_active("u1")
assert [c.surface_ref for c in chats] == ["!r2:example"]
assert any(isinstance(r, OutgoingMessage) and "!r2:example" in r.text for r in result)
async def test_invite_event_creates_dm_room_and_sends_welcome():
runtime = build_runtime(platform=MockPlatformClient())
client = SimpleNamespace(join=AsyncMock(), room_send=AsyncMock())
room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice DM")
event = SimpleNamespace(sender="@alice:example.org", membership="invite")
await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr)
client.join.assert_awaited_once_with("!dm:example.org")
client.room_send.assert_awaited_once()
meta = await get_room_meta(runtime.store, "!dm:example.org")
assert meta is not None
assert meta["chat_id"] == "C1"
assert meta["matrix_user_id"] == "@alice:example.org"
assert await runtime.auth_mgr.is_authenticated("@alice:example.org") is True
async def test_invite_event_is_idempotent_per_room():
runtime = build_runtime(platform=MockPlatformClient())
client = SimpleNamespace(join=AsyncMock(), room_send=AsyncMock())
room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice DM")
event = SimpleNamespace(sender="@alice:example.org", membership="invite")
await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr)
await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr)
client.join.assert_awaited_once_with("!dm:example.org")
client.room_send.assert_awaited_once()
async def test_bot_ignores_its_own_messages():
runtime = build_runtime(platform=MockPlatformClient())
client = SimpleNamespace(user_id="@bot:example.org")
bot = MatrixBot(client, runtime)
bot._send_all = AsyncMock()
runtime.dispatcher.dispatch = AsyncMock()
room = SimpleNamespace(room_id="!dm:example.org")
event = SimpleNamespace(sender="@bot:example.org", body="hello")
await bot.on_room_message(room, event)
runtime.dispatcher.dispatch.assert_not_awaited()
bot._send_all.assert_not_awaited()

View file

@ -0,0 +1,33 @@
from __future__ import annotations
from adapter.matrix.reactions import (
build_confirmation_text,
build_skills_text,
reaction_to_skill_index,
)
from sdk.interface import UserSettings
def test_build_skills_text():
settings = UserSettings(
skills={"web-search": True, "fetch-url": False},
connectors={},
soul={},
safety={},
plan={},
)
text = build_skills_text(settings)
assert "web-search" in text
assert "fetch-url" in text
assert "Реакции 1⃣-9" in text
def test_build_confirmation_text():
text = build_confirmation_text("Отправить письмо?")
assert "Отправить письмо?" in text
assert "подтвердить" in text
def test_reaction_to_skill_index():
assert reaction_to_skill_index("1") == 1
assert reaction_to_skill_index("👍") is None

View file

@ -0,0 +1,72 @@
from __future__ import annotations
import pytest
from adapter.matrix.store import (
get_room_meta,
get_room_state,
get_skills_message_id,
get_user_meta,
next_chat_id,
set_room_meta,
set_room_state,
set_skills_message_id,
set_user_meta,
)
from core.store import InMemoryStore
@pytest.fixture
def store() -> InMemoryStore:
return InMemoryStore()
async def test_room_meta_roundtrip(store: InMemoryStore):
meta = {
"room_type": "chat",
"chat_id": "C1",
"display_name": "Чат 1",
"matrix_user_id": "@alice:m.org",
}
await set_room_meta(store, "!r:m.org", meta)
assert await get_room_meta(store, "!r:m.org") == meta
async def test_room_meta_missing(store: InMemoryStore):
assert await get_room_meta(store, "!nonexistent:m.org") is None
async def test_user_meta_roundtrip(store: InMemoryStore):
meta = {
"platform_user_id": "usr-1",
"display_name": "Alice",
"space_id": None,
"settings_room_id": None,
"next_chat_index": 1,
}
await set_user_meta(store, "@alice:m.org", meta)
assert await get_user_meta(store, "@alice:m.org") == meta
async def test_room_state_roundtrip(store: InMemoryStore):
await set_room_state(store, "!r:m.org", "idle")
assert await get_room_state(store, "!r:m.org") == "idle"
await set_room_state(store, "!r:m.org", "waiting_response")
assert await get_room_state(store, "!r:m.org") == "waiting_response"
async def test_room_state_default_idle(store: InMemoryStore):
assert await get_room_state(store, "!unknown:m.org") == "idle"
async def test_next_chat_id_increments(store: InMemoryStore):
uid = "@alice:m.org"
await set_user_meta(store, uid, {"next_chat_index": 1})
assert await next_chat_id(store, uid) == "C1"
assert await next_chat_id(store, uid) == "C2"
assert await next_chat_id(store, uid) == "C3"
async def test_skills_message_roundtrip(store: InMemoryStore):
await set_skills_message_id(store, "!room", "$event")
assert await get_skills_message_id(store, "!room") == "$event"

View file

@ -2,7 +2,7 @@
import pytest
from core.auth import AuthManager
from core.store import InMemoryStore
from platform.mock import MockPlatformClient
from sdk.mock import MockPlatformClient
@pytest.fixture

View file

@ -2,7 +2,7 @@
import pytest
from core.chat import ChatManager
from core.store import InMemoryStore
from platform.mock import MockPlatformClient
from sdk.mock import MockPlatformClient
@pytest.fixture

View file

@ -9,7 +9,7 @@ from core.chat import ChatManager
from core.auth import AuthManager
from core.settings import SettingsManager
from core.store import InMemoryStore
from platform.mock import MockPlatformClient
from sdk.mock import MockPlatformClient
@pytest.fixture

View file

@ -4,7 +4,7 @@ Smoke test: полный цикл через dispatcher + реальные manag
Имитирует что делает адаптер (Telegram или Matrix) при получении события.
"""
import pytest
from platform.mock import MockPlatformClient
from sdk.mock import MockPlatformClient
from core.store import InMemoryStore
from core.chat import ChatManager
from core.auth import AuthManager

View file

@ -3,7 +3,7 @@ import pytest
from core.settings import SettingsManager
from core.store import InMemoryStore
from core.protocol import SettingsAction
from platform.mock import MockPlatformClient
from sdk.mock import MockPlatformClient
@pytest.fixture

View file

@ -6,7 +6,7 @@ from core.store import InMemoryStore
from core.auth import AuthManager
from core.chat import ChatManager
from core.settings import SettingsManager
from platform.mock import MockPlatformClient
from sdk.mock import MockPlatformClient
@pytest.fixture

View file

@ -1,6 +1,6 @@
# tests/platform/test_mock.py
from platform.mock import MockPlatformClient
from platform.interface import User, MessageResponse, UserSettings
from sdk.mock import MockPlatformClient
from sdk.interface import User, MessageResponse, UserSettings
from core.protocol import SettingsAction

1541
uv.lock generated Normal file

File diff suppressed because it is too large Load diff