surfaces/docs/superpowers/plans/2026-03-31-matrix-adapter.md
Mikhail Putilovskij 6ced154124 feat(matrix): land QA follow-ups and refresh docs
- harden Matrix onboarding/chat lifecycle after manual QA
- refresh README and Matrix docs to match current behavior
- add local ignores for runtime artifacts and include current planning/report docs

Closes #7
Closes #9
Closes #14
2026-04-05 19:08:58 +03:00

57 KiB
Raw Blame History

Matrix Adapter 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: Implement adapter/matrix/ — Matrix bot using matrix-nio that connects to the Lambda platform via EventDispatcher and MockPlatformClient.

Architecture: Room-type routing — each incoming event is classified by room type (chat/settings) then dispatched. DM room = C1 (first chat). Space and Settings room created lazily on first !new. Core business logic lives in EventDispatcher; the adapter converts nio events ↔ protocol events.

Tech Stack: matrix-nio 0.21+, Python 3.11+, SQLiteStore (key-value), MockPlatformClient, pytest-asyncio


File map

File Responsibility
adapter/matrix/store.py Key-prefix helpers for room/user metadata in StateStore
adapter/matrix/converter.py nio event → IncomingEvent, extract_attachments
adapter/matrix/reactions.py add_reaction, edit_message, build_skills_text
adapter/matrix/handlers/auth.py Invite → join + register room + welcome message
adapter/matrix/handlers/chat.py Text messages, !new, !chats
adapter/matrix/handlers/confirm.py 👍/ reactions + !yes/!no
adapter/matrix/handlers/settings.py !skills (m.replace), !soul, !safety, !plan, !status, !whoami, !connectors
adapter/matrix/bot.py AsyncClient, sync loop, event routing

Store key conventions (all via StateStore KV):

  • matrix_room:{room_id}{room_type, chat_id, display_name, matrix_user_id}
  • matrix_user:{matrix_user_id}{platform_user_id, display_name, space_id, settings_room_id, next_chat_index}
  • matrix_state:{room_id}{state} — one of idle | waiting_response | confirm_pending | settings_active
  • matrix_skills_msg:{room_id}{event_id} — event_id of the last !skills message (for m.replace)

Task 1: Store helpers

Files:

  • Create: adapter/matrix/__init__.py

  • Create: adapter/matrix/store.py

  • Create: tests/adapter/__init__.py

  • Create: tests/adapter/matrix/__init__.py

  • Create: tests/adapter/matrix/test_store.py

  • Step 1: Write failing test

# tests/adapter/matrix/test_store.py
import pytest
from core.store import InMemoryStore
from adapter.matrix.store import (
    get_room_meta, set_room_meta,
    get_user_meta, set_user_meta,
    get_room_state, set_room_state,
    next_chat_id,
)


@pytest.fixture
def store():
    return InMemoryStore()


async def test_room_meta_roundtrip(store):
    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):
    assert await get_room_meta(store, "!nonexistent:m.org") is None


async def test_user_meta_roundtrip(store):
    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):
    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):
    assert await get_room_state(store, "!unknown:m.org") == "idle"


async def test_next_chat_id_increments(store):
    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"
  • Step 2: Run — expect ImportError
cd /path/to/surfaces-bot && pytest tests/adapter/matrix/test_store.py -v
  • Step 3: Create __init__.py files
touch adapter/__init__.py adapter/matrix/__init__.py tests/adapter/__init__.py tests/adapter/matrix/__init__.py
  • Step 4: Implement store.py
# adapter/matrix/store.py
from __future__ import annotations
from core.store import StateStore


async def get_room_meta(store: StateStore, room_id: str) -> dict | None:
    return await store.get(f"matrix_room:{room_id}")


async def set_room_meta(store: StateStore, room_id: str, meta: dict) -> None:
    await store.set(f"matrix_room:{room_id}", meta)


async def get_user_meta(store: StateStore, matrix_user_id: str) -> dict | None:
    return await store.get(f"matrix_user:{matrix_user_id}")


async def set_user_meta(store: StateStore, matrix_user_id: str, meta: dict) -> None:
    await store.set(f"matrix_user:{matrix_user_id}", meta)


async def get_room_state(store: StateStore, room_id: str) -> str:
    data = await store.get(f"matrix_state:{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"matrix_state:{room_id}", {"state": state})


async def next_chat_id(store: StateStore, matrix_user_id: str) -> str:
    """Allocate next chat_id (C1, C2, ...) and increment counter in user meta."""
    meta = await get_user_meta(store, matrix_user_id) or {}
    index = 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}"
  • Step 5: Run — expect all PASS
pytest tests/adapter/matrix/test_store.py -v

Expected: 6 tests PASS.

  • Step 6: Commit
git add adapter/__init__.py adapter/matrix/__init__.py adapter/matrix/store.py \
        tests/adapter/__init__.py tests/adapter/matrix/__init__.py tests/adapter/matrix/test_store.py
git commit -m "feat(matrix): room/user store helpers"

Task 2: Converter

Files:

  • Create: adapter/matrix/converter.py

  • Create: tests/adapter/matrix/test_converter.py

  • Step 1: Write failing tests

# tests/adapter/matrix/test_converter.py
from types import SimpleNamespace
from core.protocol import Attachment, IncomingCallback, IncomingCommand, IncomingMessage
from adapter.matrix.converter import from_room_event


def text_event(body, sender="@a:m.org", event_id="$e1"):
    return SimpleNamespace(sender=sender, body=body, event_id=event_id,
                           msgtype="m.text", replyto_event_id=None)


def file_event(url="mxc://x/y", filename="doc.pdf", mime="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="mxc://x/img", mime="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 audio_event(url="mxc://x/audio", mime="audio/ogg"):
    return SimpleNamespace(sender="@a:m.org", body="voice.ogg", event_id="$e4",
                           msgtype="m.audio", replyto_event_id=None,
                           url=url, mimetype=mime)


def reaction_event(key, reacted_to="$orig"):
    return SimpleNamespace(sender="@a:m.org", key=key, reacted_to_id=reacted_to, event_id="$r1")


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_bang_command_no_args():
    result = from_room_event(text_event("!skills"), room_id="!r:m.org", chat_id="C1")
    assert isinstance(result, IncomingCommand)
    assert result.command == "skills"
    assert result.args == []


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_audio_attachment():
    result = from_room_event(audio_event(), room_id="!r:m.org", chat_id="C1")
    assert result.attachments[0].type == "audio"


async def test_confirm_reaction():
    result = from_room_event(reaction_event("👍"), room_id="!r:m.org", chat_id="C1", is_reaction=True)
    assert isinstance(result, IncomingCallback)
    assert result.action == "confirm"


async def test_cancel_reaction():
    result = from_room_event(reaction_event("❌"), room_id="!r:m.org", chat_id="C1", is_reaction=True)
    assert isinstance(result, IncomingCallback)
    assert result.action == "cancel"


async def test_skill_reaction_index():
    result = from_room_event(reaction_event("4⃣"), room_id="!r:m.org", chat_id="C1", is_reaction=True)
    assert isinstance(result, IncomingCallback)
    assert result.action == "toggle_skill"
    assert result.payload["skill_index"] == 3  # 0-based


async def test_unknown_reaction_returns_none():
    result = from_room_event(reaction_event("🎉"), room_id="!r:m.org", chat_id="C1", is_reaction=True)
    assert result is None
  • Step 2: Run — expect ImportError
pytest tests/adapter/matrix/test_converter.py -v
  • Step 3: Implement converter.py
# adapter/matrix/converter.py
from __future__ import annotations
from core.protocol import Attachment, IncomingCallback, IncomingCommand, IncomingEvent, IncomingMessage

SKILL_REACTIONS = ["1⃣", "2⃣", "3⃣", "4⃣", "5⃣", "6⃣", "7⃣", "8⃣", "9⃣"]
CONFIRM_REACTIONS = {"👍": "confirm", "❌": "cancel"}
_CALLBACK_COMMANDS = {"yes": "confirm", "no": "cancel"}


def from_room_event(
    event,
    room_id: str,
    chat_id: str,
    is_reaction: bool = False,
) -> IncomingEvent | None:
    """Convert a nio event object to an IncomingEvent. Returns None if unrecognised."""
    if is_reaction:
        return _from_reaction(event, chat_id)

    body: str = event.body

    if body.startswith("!"):
        parts = body[1:].split(maxsplit=1)
        cmd = parts[0].lower()
        args = parts[1].split() if len(parts) > 1 else []

        if cmd in _CALLBACK_COMMANDS:
            return IncomingCallback(
                user_id=event.sender, platform="matrix", chat_id=chat_id,
                action=_CALLBACK_COMMANDS[cmd], payload={},
            )
        return IncomingCommand(
            user_id=event.sender, platform="matrix", chat_id=chat_id,
            command=cmd, args=args,
        )

    return IncomingMessage(
        user_id=event.sender, platform="matrix", chat_id=chat_id,
        text=body if event.msgtype == "m.text" else "",
        attachments=extract_attachments(event),
        reply_to=getattr(event, "replyto_event_id", None),
    )


def extract_attachments(event) -> list[Attachment]:
    msgtype = getattr(event, "msgtype", "m.text")
    url = getattr(event, "url", None)
    mime = getattr(event, "mimetype", None)

    if msgtype == "m.image":
        return [Attachment(type="image", url=url, mime_type=mime)]
    if msgtype == "m.file":
        return [Attachment(type="document", url=url, filename=event.body, mime_type=mime)]
    if msgtype == "m.audio":
        return [Attachment(type="audio", url=url, mime_type=mime)]
    return []


def _from_reaction(event, chat_id: str) -> IncomingCallback | None:
    key = event.key
    if key in CONFIRM_REACTIONS:
        return IncomingCallback(
            user_id=event.sender, platform="matrix", chat_id=chat_id,
            action=CONFIRM_REACTIONS[key],
            payload={"reacted_to_id": event.reacted_to_id},
        )
    if key in SKILL_REACTIONS:
        return IncomingCallback(
            user_id=event.sender, platform="matrix", chat_id=chat_id,
            action="toggle_skill",
            payload={"skill_index": SKILL_REACTIONS.index(key), "reacted_to_id": event.reacted_to_id},
        )
    return None
  • Step 4: Run — expect all PASS
pytest tests/adapter/matrix/test_converter.py -v
  • Step 5: Commit
git add adapter/matrix/converter.py tests/adapter/matrix/test_converter.py
git commit -m "feat(matrix): event converter"

Task 3: Reactions helpers

Files:

  • Create: adapter/matrix/reactions.py

  • Create: tests/adapter/matrix/test_reactions.py

  • Step 1: Write failing tests

# tests/adapter/matrix/test_reactions.py
from unittest.mock import AsyncMock
from adapter.matrix.reactions import add_reaction, edit_message, build_skills_text
from sdk.interface import UserSettings


async def test_add_reaction():
    client = AsyncMock()
    await add_reaction(client, "!r:m.org", "$evt", "👍")
    client.room_send.assert_called_once_with(
        "!r:m.org", "m.reaction",
        {"m.relates_to": {"rel_type": "m.annotation", "event_id": "$evt", "key": "👍"}},
    )


async def test_edit_message():
    client = AsyncMock()
    await edit_message(client, "!r:m.org", "$orig", "new text")
    client.room_send.assert_called_once_with(
        "!r:m.org", "m.room.message",
        {
            "msgtype": "m.text",
            "body": "* new text",
            "m.new_content": {"msgtype": "m.text", "body": "new text"},
            "m.relates_to": {"rel_type": "m.replace", "event_id": "$orig"},
        },
    )


def test_build_skills_text_shows_status():
    settings = UserSettings(skills={"web-search": True, "browser": False})
    text = build_skills_text(settings)
    assert "✅ 1 web-search" in text
    assert "❌ 2 browser" in text


def test_build_skills_text_has_reaction_hint():
    settings = UserSettings(skills={"web-search": True, "browser": False})
    text = build_skills_text(settings)
    assert "1⃣" in text
    assert "Реакция" in text
  • Step 2: Run — expect ImportError
pytest tests/adapter/matrix/test_reactions.py -v
  • Step 3: Implement reactions.py
# adapter/matrix/reactions.py
from __future__ import annotations
from adapter.matrix.converter import SKILL_REACTIONS
from sdk.interface import UserSettings

_SKILL_DESCRIPTIONS: dict[str, str] = {
    "web-search": "поиск в интернете",
    "fetch-url": "чтение веб-страниц",
    "email": "чтение почты",
    "browser": "управление браузером",
    "image-gen": "генерация изображений",
    "video-gen": "генерация видео",
    "files": "работа с файлами",
    "calendar": "календарь",
}


async def add_reaction(client, room_id: str, event_id: str, key: str) -> None:
    await client.room_send(
        room_id, "m.reaction",
        {"m.relates_to": {"rel_type": "m.annotation", "event_id": event_id, "key": key}},
    )


async def edit_message(client, room_id: str, original_event_id: str, new_body: str) -> None:
    await client.room_send(
        room_id, "m.room.message",
        {
            "msgtype": "m.text",
            "body": f"* {new_body}",
            "m.new_content": {"msgtype": "m.text", "body": new_body},
            "m.relates_to": {"rel_type": "m.replace", "event_id": original_event_id},
        },
    )


def build_skills_text(settings: UserSettings) -> str:
    skill_names = list(settings.skills.keys())
    lines = []
    for i, name in enumerate(skill_names):
        enabled = settings.skills[name]
        emoji = "✅" if enabled else "❌"
        desc = _SKILL_DESCRIPTIONS.get(name, name)
        lines.append(f"{emoji} {i + 1} {name}{desc}")

    hint = " ".join(SKILL_REACTIONS[i] for i in range(min(len(skill_names), len(SKILL_REACTIONS))))
    lines += ["", f"Реакция {hint} = переключить скилл"]
    return "\n".join(lines)
  • Step 4: Run — expect all PASS
pytest tests/adapter/matrix/test_reactions.py -v
  • Step 5: Commit
git add adapter/matrix/reactions.py tests/adapter/matrix/test_reactions.py
git commit -m "feat(matrix): reactions and edit helpers"

Task 4: Auth handler — invite → onboarding

Files:

  • Create: adapter/matrix/handlers/__init__.py

  • Create: adapter/matrix/handlers/auth.py

  • Create: tests/adapter/matrix/test_auth.py

  • Step 1: Write failing tests

# tests/adapter/matrix/test_auth.py
import pytest
from unittest.mock import AsyncMock
from core.store import InMemoryStore
from core.auth import AuthManager
from sdk.mock import MockPlatformClient
from adapter.matrix.handlers.auth import handle_invite
from adapter.matrix.store import get_room_meta, get_room_state, get_user_meta


@pytest.fixture
def store():
    return InMemoryStore()


@pytest.fixture
def platform():
    return MockPlatformClient()


@pytest.fixture
def client():
    c = AsyncMock()
    c.join = AsyncMock()
    c.room_send = AsyncMock()
    return c


async def test_invite_joins_room(client, store, platform):
    await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform, display_name="Alice")
    client.join.assert_called_once_with("!dm:m.org")


async def test_invite_sends_welcome_with_name(client, store, platform):
    await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform, display_name="Alice")
    body = client.room_send.call_args[0][2]["body"]
    assert "Alice" in body
    assert "!new" in body


async def test_invite_registers_room_as_c1(client, store, platform):
    await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform)
    meta = await get_room_meta(store, "!dm:m.org")
    assert meta["room_type"] == "chat"
    assert meta["chat_id"] == "C1"
    assert meta["matrix_user_id"] == "@alice:m.org"


async def test_invite_creates_platform_user(client, store, platform):
    await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform, display_name="Alice")
    user_meta = await get_user_meta(store, "@alice:m.org")
    assert user_meta is not None
    assert "platform_user_id" in user_meta


async def test_invite_authenticates_user(client, store, platform):
    await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform)
    auth_mgr = AuthManager(platform, store)
    assert await auth_mgr.is_authenticated("@alice:m.org")


async def test_invite_room_state_idle(client, store, platform):
    await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform)
    assert await get_room_state(store, "!dm:m.org") == "idle"


async def test_second_invite_gets_c2(client, store, platform):
    await handle_invite(client, "!dm1:m.org", "@alice:m.org", store, platform)
    await handle_invite(client, "!dm2:m.org", "@alice:m.org", store, platform)
    meta = await get_room_meta(store, "!dm2:m.org")
    assert meta["chat_id"] == "C2"
  • Step 2: Run — expect ImportError
pytest tests/adapter/matrix/test_auth.py -v
  • Step 3: Create __init__.py and implement auth.py
# adapter/matrix/handlers/__init__.py
# (empty)
# adapter/matrix/handlers/auth.py
from __future__ import annotations
import structlog
from adapter.matrix.store import (
    get_user_meta, next_chat_id,
    set_room_meta, set_room_state, set_user_meta,
)
from core.auth import AuthManager
from sdk.interface import PlatformClient

logger = structlog.get_logger(__name__)


async def handle_invite(
    client,
    room_id: str,
    matrix_user_id: str,
    store,
    platform: PlatformClient,
    display_name: str | None = None,
) -> None:
    """Accept invite, register DM room as first chat, authenticate user, send welcome."""
    await client.join(room_id)
    logger.info("Joined room", room_id=room_id, user=matrix_user_id)

    user = await platform.get_or_create_user(matrix_user_id, "matrix", display_name)

    user_meta = await get_user_meta(store, matrix_user_id)
    if user_meta is None:
        user_meta = {
            "platform_user_id": user.user_id,
            "display_name": display_name,
            "space_id": None,
            "settings_room_id": None,
            "next_chat_index": 1,
        }
        await set_user_meta(store, matrix_user_id, user_meta)

    auth_mgr = AuthManager(platform, store)
    await auth_mgr.confirm(matrix_user_id)

    chat_id = await next_chat_id(store, matrix_user_id)
    chat_num = chat_id[1:]
    await set_room_meta(store, room_id, {
        "room_type": "chat",
        "chat_id": chat_id,
        "display_name": f"Чат {chat_num}",
        "matrix_user_id": matrix_user_id,
    })
    await set_room_state(store, room_id, "idle")

    name = display_name or matrix_user_id.split(":")[0].lstrip("@")
    welcome = (
        f"Привет, {name}! Пиши — я здесь.\n\n"
        "Команды: !new · !chats · !rename · !archive · !skills"
    )
    await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": welcome})
  • Step 4: Run — expect all PASS
pytest tests/adapter/matrix/test_auth.py -v
  • Step 5: Commit
git add adapter/matrix/handlers/__init__.py adapter/matrix/handlers/auth.py tests/adapter/matrix/test_auth.py
git commit -m "feat(matrix): invite handler + onboarding"

Task 5: Chat handler — messages + !new + !chats

Files:

  • Create: adapter/matrix/handlers/chat.py

  • Create: tests/adapter/matrix/test_chat_handler.py

  • Step 1: Write failing tests

# tests/adapter/matrix/test_chat_handler.py
import pytest
from types import SimpleNamespace
from unittest.mock import AsyncMock
from core.store import InMemoryStore
from core.auth import AuthManager
from core.chat import ChatManager
from core.settings import SettingsManager
from core.handler import EventDispatcher
from core.handlers import register_all
from sdk.mock import MockPlatformClient
from adapter.matrix.store import get_room_meta, set_room_meta, set_room_state, set_user_meta
from adapter.matrix.handlers.chat import handle_message, handle_new_chat, handle_list_chats


@pytest.fixture
def store():
    return InMemoryStore()


@pytest.fixture
def platform():
    return MockPlatformClient()


@pytest.fixture
def dispatcher(platform, store):
    d = EventDispatcher(
        platform=platform,
        chat_mgr=ChatManager(platform, store),
        auth_mgr=AuthManager(platform, store),
        settings_mgr=SettingsManager(platform, store),
    )
    register_all(d)
    return d


@pytest.fixture
def client():
    c = AsyncMock()
    c.room_send = AsyncMock()
    c.room_typing = AsyncMock()
    c.room_create = AsyncMock(return_value=AsyncMock(room_id="!new:m.org"))
    c.room_invite = AsyncMock()
    c.room_put_state = AsyncMock()
    return c


async def _setup(store, platform, room_id="!dm:m.org", uid="@alice:m.org"):
    user = await platform.get_or_create_user(uid, "matrix", "Alice")
    await set_user_meta(store, uid, {
        "platform_user_id": user.user_id,
        "display_name": "Alice",
        "space_id": None,
        "settings_room_id": None,
        "next_chat_index": 2,
    })
    await set_room_meta(store, room_id, {
        "room_type": "chat", "chat_id": "C1",
        "display_name": "Чат 1", "matrix_user_id": uid,
    })
    await set_room_state(store, room_id, "idle")
    auth = AuthManager(platform, store)
    await auth.confirm(uid)


def _text_event(body, sender="@alice:m.org"):
    return SimpleNamespace(sender=sender, body=body, event_id="$e1",
                           msgtype="m.text", replyto_event_id=None)


async def test_message_gets_response(client, store, platform, dispatcher):
    await _setup(store, platform)
    await handle_message(client, "!dm:m.org", _text_event("Hello"), store, platform, dispatcher)
    texts = [str(c) for c in client.room_send.call_args_list]
    assert any("[MOCK]" in t for t in texts)


async def test_message_sends_typing(client, store, platform, dispatcher):
    await _setup(store, platform)
    await handle_message(client, "!dm:m.org", _text_event("Hello"), store, platform, dispatcher)
    client.room_typing.assert_called()


async def test_new_creates_matrix_room(client, store, platform, dispatcher):
    await _setup(store, platform)
    await handle_new_chat(client, "!dm:m.org", _text_event("!new Analysis"), store, platform, dispatcher)
    client.room_create.assert_called()
    client.room_invite.assert_called()


async def test_new_registers_room_meta(client, store, platform, dispatcher):
    await _setup(store, platform)
    await handle_new_chat(client, "!dm:m.org", _text_event("!new Analysis"), store, platform, dispatcher)
    meta = await get_room_meta(store, "!new:m.org")
    assert meta is not None
    assert meta["room_type"] == "chat"
    assert meta["display_name"] == "Analysis"


async def test_list_chats_includes_room_name(client, store, platform, dispatcher):
    await _setup(store, platform)
    await handle_list_chats(client, "!dm:m.org", "@alice:m.org", store)
    body = client.room_send.call_args[0][2]["body"]
    assert "Чат 1" in body
  • Step 2: Run — expect ImportError
pytest tests/adapter/matrix/test_chat_handler.py -v
  • Step 3: Implement handlers/chat.py
# adapter/matrix/handlers/chat.py
from __future__ import annotations
import asyncio
import structlog
from adapter.matrix.converter import from_room_event
from adapter.matrix.store import (
    get_room_meta, get_user_meta,
    next_chat_id, set_room_meta, set_room_state, set_user_meta,
)
from core.protocol import OutgoingMessage, OutgoingTyping
from sdk.interface import PlatformClient

logger = structlog.get_logger(__name__)
_TYPING_INTERVAL = 25  # nio typing expires ~30s


async def handle_message(client, room_id: str, event, store, platform: PlatformClient, dispatcher) -> None:
    room_meta = await get_room_meta(store, room_id)
    if room_meta is None:
        return

    incoming = from_room_event(event, room_id=room_id, chat_id=room_meta["chat_id"])
    if incoming is None:
        return

    await set_room_state(store, room_id, "waiting_response")
    await client.room_typing(room_id, True, timeout=_TYPING_INTERVAL * 1000)

    typing_task = asyncio.create_task(_keep_typing(client, room_id, _TYPING_INTERVAL))
    try:
        outgoing_events = await dispatcher.dispatch(incoming)
    finally:
        typing_task.cancel()
        await client.room_typing(room_id, False, timeout=0)

    await set_room_state(store, room_id, "idle")
    for out in outgoing_events:
        await _send(client, room_id, out)


async def handle_new_chat(client, room_id: str, event, store, platform: PlatformClient, dispatcher) -> None:
    room_meta = await get_room_meta(store, room_id)
    if room_meta is None:
        return

    matrix_user_id = room_meta["matrix_user_id"]
    parts = event.body[1:].split(maxsplit=1)  # "!new Analysis" → ["new", "Analysis"]
    display_name_arg = parts[1] if len(parts) > 1 else None

    chat_id = await next_chat_id(store, matrix_user_id)
    chat_num = chat_id[1:]
    display_name = display_name_arg or f"Чат {chat_num}"

    response = await client.room_create(name=display_name)
    new_room_id = response.room_id
    await client.room_invite(new_room_id, matrix_user_id)

    user_meta = await get_user_meta(store, matrix_user_id) or {}
    space_id = user_meta.get("space_id")
    if space_id is None:
        space_id = await _create_space(client, store, matrix_user_id, user_meta)

    await client.room_put_state(space_id, "m.space.child", {"via": []}, state_key=new_room_id)
    await client.room_put_state(space_id, "m.space.child", {"via": []}, state_key=room_id)

    await set_room_meta(store, new_room_id, {
        "room_type": "chat", "chat_id": chat_id,
        "display_name": display_name, "matrix_user_id": matrix_user_id,
    })
    await set_room_state(store, new_room_id, "idle")

    await client.room_send(
        room_id, "m.room.message",
        {"msgtype": "m.text", "body": f"✅ [{display_name}] создан. Перейди в комнату."},
    )


async def handle_list_chats(client, room_id: str, matrix_user_id: str, store) -> None:
    all_keys = await store.keys("matrix_room:")
    chats = []
    for key in all_keys:
        meta = await store.get(key)
        if (meta and meta.get("matrix_user_id") == matrix_user_id
                and meta.get("room_type") == "chat"):
            chats.append(meta)

    if not chats:
        body = "Нет активных чатов. Напиши !new чтобы создать."
    else:
        lines = ["Твои чаты:"]
        for chat in chats:
            lines.append(f"  {chat['display_name']} ({chat['chat_id']})")
        body = "\n".join(lines)

    await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body})


async def _create_space(client, store, matrix_user_id: str, user_meta: dict) -> str:
    name = user_meta.get("display_name") or matrix_user_id.split(":")[0].lstrip("@")
    space_resp = await client.room_create(
        name=f"Lambda — {name}",
        initial_state=[{"type": "m.room.create", "content": {"type": "m.space"}}],
    )
    space_id = space_resp.room_id
    await client.room_invite(space_id, matrix_user_id)

    settings_resp = await client.room_create(name="⚙️ Настройки")
    settings_room_id = settings_resp.room_id
    await client.room_invite(settings_room_id, matrix_user_id)
    await client.room_put_state(space_id, "m.space.child", {"via": []}, state_key=settings_room_id)

    await set_room_meta(store, settings_room_id, {
        "room_type": "settings", "chat_id": None,
        "display_name": "Настройки", "matrix_user_id": matrix_user_id,
    })
    await set_room_state(store, settings_room_id, "settings_active")

    user_meta["space_id"] = space_id
    user_meta["settings_room_id"] = settings_room_id
    await set_user_meta(store, matrix_user_id, user_meta)
    return space_id


async def _keep_typing(client, room_id: str, interval: int) -> None:
    try:
        while True:
            await asyncio.sleep(interval)
            await client.room_typing(room_id, True, timeout=interval * 1000)
    except asyncio.CancelledError:
        pass


async def _send(client, room_id: str, event) -> None:
    if isinstance(event, OutgoingMessage):
        await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": event.text})
    elif isinstance(event, OutgoingTyping):
        await client.room_typing(room_id, event.is_typing, timeout=0)
  • Step 4: Run — expect all PASS
pytest tests/adapter/matrix/test_chat_handler.py -v
  • Step 5: Commit
git add adapter/matrix/handlers/chat.py tests/adapter/matrix/test_chat_handler.py
git commit -m "feat(matrix): chat handler — messages, !new, !chats"

Task 6: Confirm handler — 👍/ + !yes/!no

Files:

  • Create: adapter/matrix/handlers/confirm.py

  • Create: tests/adapter/matrix/test_confirm.py

  • Step 1: Write failing tests

# tests/adapter/matrix/test_confirm.py
import pytest
from types import SimpleNamespace
from unittest.mock import AsyncMock
from core.store import InMemoryStore
from core.auth import AuthManager
from core.chat import ChatManager
from core.settings import SettingsManager
from core.handler import EventDispatcher
from core.handlers import register_all
from sdk.mock import MockPlatformClient
from adapter.matrix.store import get_room_state, set_room_meta, set_room_state
from adapter.matrix.handlers.confirm import handle_confirm_callback


@pytest.fixture
def store():
    return InMemoryStore()


@pytest.fixture
def platform():
    return MockPlatformClient()


@pytest.fixture
def dispatcher(platform, store):
    d = EventDispatcher(
        platform=platform,
        chat_mgr=ChatManager(platform, store),
        auth_mgr=AuthManager(platform, store),
        settings_mgr=SettingsManager(platform, store),
    )
    register_all(d)
    return d


@pytest.fixture
def client():
    return AsyncMock()


async def _setup(store, platform, room_id="!dm:m.org", uid="@alice:m.org"):
    await platform.get_or_create_user(uid, "matrix", "Alice")
    await set_room_meta(store, room_id, {
        "room_type": "chat", "chat_id": "C1",
        "display_name": "Чат 1", "matrix_user_id": uid,
    })
    await set_room_state(store, room_id, "confirm_pending")
    await AuthManager(platform, store).confirm(uid)


async def test_yes_command_transitions_to_idle(client, store, platform, dispatcher):
    await _setup(store, platform)
    event = SimpleNamespace(sender="@alice:m.org", body="!yes", event_id="$e1",
                            msgtype="m.text", replyto_event_id=None)
    await handle_confirm_callback(client, "!dm:m.org", event, store, platform, dispatcher, is_reaction=False)
    assert await get_room_state(store, "!dm:m.org") == "idle"


async def test_no_command_transitions_to_idle(client, store, platform, dispatcher):
    await _setup(store, platform)
    event = SimpleNamespace(sender="@alice:m.org", body="!no", event_id="$e1",
                            msgtype="m.text", replyto_event_id=None)
    await handle_confirm_callback(client, "!dm:m.org", event, store, platform, dispatcher, is_reaction=False)
    assert await get_room_state(store, "!dm:m.org") == "idle"


async def test_thumbs_up_reaction_transitions_to_idle(client, store, platform, dispatcher):
    await _setup(store, platform)
    event = SimpleNamespace(sender="@alice:m.org", key="👍",
                            reacted_to_id="$orig", event_id="$r1")
    await handle_confirm_callback(client, "!dm:m.org", event, store, platform, dispatcher, is_reaction=True)
    assert await get_room_state(store, "!dm:m.org") == "idle"


async def test_confirm_sends_response(client, store, platform, dispatcher):
    await _setup(store, platform)
    event = SimpleNamespace(sender="@alice:m.org", body="!yes", event_id="$e1",
                            msgtype="m.text", replyto_event_id=None)
    await handle_confirm_callback(client, "!dm:m.org", event, store, platform, dispatcher, is_reaction=False)
    client.room_send.assert_called()


async def test_noop_when_state_not_confirm_pending(client, store, platform, dispatcher):
    await _setup(store, platform)
    await set_room_state(store, "!dm:m.org", "idle")  # wrong state
    event = SimpleNamespace(sender="@alice:m.org", body="!yes", event_id="$e1",
                            msgtype="m.text", replyto_event_id=None)
    await handle_confirm_callback(client, "!dm:m.org", event, store, platform, dispatcher, is_reaction=False)
    client.room_send.assert_not_called()
  • Step 2: Run — expect ImportError
pytest tests/adapter/matrix/test_confirm.py -v
  • Step 3: Implement handlers/confirm.py
# adapter/matrix/handlers/confirm.py
from __future__ import annotations
import structlog
from adapter.matrix.converter import from_room_event
from adapter.matrix.store import get_room_meta, get_room_state, set_room_state
from core.protocol import OutgoingMessage
from sdk.interface import PlatformClient

logger = structlog.get_logger(__name__)


async def handle_confirm_callback(
    client,
    room_id: str,
    event,
    store,
    platform: PlatformClient,
    dispatcher,
    is_reaction: bool = False,
) -> None:
    if await get_room_state(store, room_id) != "confirm_pending":
        return

    room_meta = await get_room_meta(store, room_id)
    if room_meta is None:
        return

    incoming = from_room_event(event, room_id=room_id,
                               chat_id=room_meta["chat_id"], is_reaction=is_reaction)
    if incoming is None or getattr(incoming, "action", None) not in ("confirm", "cancel"):
        return

    await set_room_state(store, room_id, "idle")
    outgoing_events = await dispatcher.dispatch(incoming)

    for out in outgoing_events:
        if isinstance(out, OutgoingMessage):
            await client.room_send(room_id, "m.room.message",
                                   {"msgtype": "m.text", "body": out.text})
  • Step 4: Run — expect all PASS
pytest tests/adapter/matrix/test_confirm.py -v
  • Step 5: Commit
git add adapter/matrix/handlers/confirm.py tests/adapter/matrix/test_confirm.py
git commit -m "feat(matrix): confirm handler — reactions and !yes/!no"

Task 7: Settings handler — !skills (m.replace) + other commands

Files:

  • Create: adapter/matrix/handlers/settings.py

  • Create: tests/adapter/matrix/test_settings_handler.py

  • Step 1: Write failing tests

# tests/adapter/matrix/test_settings_handler.py
import pytest
from unittest.mock import AsyncMock
from core.store import InMemoryStore
from core.auth import AuthManager
from core.chat import ChatManager
from core.settings import SettingsManager
from core.handler import EventDispatcher
from core.handlers import register_all
from sdk.mock import MockPlatformClient
from adapter.matrix.store import set_room_meta, set_room_state, set_user_meta
from adapter.matrix.handlers.settings import handle_skills, handle_skill_toggle, handle_text_setting


@pytest.fixture
def store():
    return InMemoryStore()


@pytest.fixture
def platform():
    return MockPlatformClient()


@pytest.fixture
def dispatcher(platform, store):
    d = EventDispatcher(
        platform=platform,
        chat_mgr=ChatManager(platform, store),
        auth_mgr=AuthManager(platform, store),
        settings_mgr=SettingsManager(platform, store),
    )
    register_all(d)
    return d


@pytest.fixture
def client():
    c = AsyncMock()
    c.room_send = AsyncMock(return_value=AsyncMock(event_id="$skills_msg"))
    return c


async def _setup(store, platform, uid="@alice:m.org", room_id="!s:m.org"):
    user = await platform.get_or_create_user(uid, "matrix", "Alice")
    await set_user_meta(store, uid, {
        "platform_user_id": user.user_id, "display_name": "Alice",
        "space_id": None, "settings_room_id": room_id, "next_chat_index": 2,
    })
    await set_room_meta(store, room_id, {
        "room_type": "settings", "chat_id": None,
        "display_name": "Настройки", "matrix_user_id": uid,
    })
    await set_room_state(store, room_id, "settings_active")
    await AuthManager(platform, store).confirm(uid)


async def test_skills_sends_list(client, store, platform, dispatcher):
    await _setup(store, platform)
    await handle_skills(client, "!s:m.org", "@alice:m.org", store, platform, dispatcher)
    body = client.room_send.call_args[0][2]["body"]
    assert "web-search" in body
    assert "Реакция" in body


async def test_skills_stores_event_id(client, store, platform, dispatcher):
    await _setup(store, platform)
    await handle_skills(client, "!s:m.org", "@alice:m.org", store, platform, dispatcher)
    stored = await store.get("matrix_skills_msg:!s:m.org")
    assert stored is not None
    assert stored["event_id"] == "$skills_msg"


async def test_skill_toggle_edits_message(client, store, platform, dispatcher):
    await _setup(store, platform)
    await store.set("matrix_skills_msg:!s:m.org", {"event_id": "$skills_msg"})
    from types import SimpleNamespace
    reaction = SimpleNamespace(sender="@alice:m.org", key="1⃣",
                               reacted_to_id="$skills_msg", event_id="$r1")
    await handle_skill_toggle(client, "!s:m.org", reaction, store, platform, dispatcher)
    content = client.room_send.call_args[0][2]
    assert content.get("m.relates_to", {}).get("rel_type") == "m.replace"


async def test_whoami_contains_user_id(client, store, platform, dispatcher):
    await _setup(store, platform)
    await handle_text_setting(client, "!s:m.org", "@alice:m.org", "whoami", [], store, platform)
    body = client.room_send.call_args[0][2]["body"]
    assert "@alice:m.org" in body


async def test_status_response(client, store, platform, dispatcher):
    await _setup(store, platform)
    await handle_text_setting(client, "!s:m.org", "@alice:m.org", "status", [], store, platform)
    body = client.room_send.call_args[0][2]["body"]
    assert "Статус" in body


async def test_plan_shows_tokens(client, store, platform, dispatcher):
    await _setup(store, platform)
    await handle_text_setting(client, "!s:m.org", "@alice:m.org", "plan", [], store, platform)
    body = client.room_send.call_args[0][2]["body"]
    assert "Beta" in body
    assert "/" in body  # "0 / 1000"
  • Step 2: Run — expect ImportError
pytest tests/adapter/matrix/test_settings_handler.py -v
  • Step 3: Implement handlers/settings.py
# adapter/matrix/handlers/settings.py
from __future__ import annotations
import structlog
from adapter.matrix.converter import SKILL_REACTIONS
from adapter.matrix.reactions import build_skills_text, edit_message
from adapter.matrix.store import get_room_meta, get_user_meta
from core.protocol import SettingsAction
from sdk.interface import PlatformClient

logger = structlog.get_logger(__name__)

_SKILL_NAMES_ORDER = ["web-search", "fetch-url", "email", "browser",
                      "image-gen", "video-gen", "files", "calendar"]


async def handle_skills(
    client, room_id: str, matrix_user_id: str, store, platform: PlatformClient, dispatcher,
) -> None:
    """Send skills list and store its event_id for later m.replace edits."""
    user_meta = await get_user_meta(store, matrix_user_id) or {}
    platform_user_id = user_meta.get("platform_user_id", matrix_user_id)
    settings = await platform.get_settings(platform_user_id)
    body = build_skills_text(settings)
    response = await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body})
    event_id = getattr(response, "event_id", None)
    if event_id:
        await store.set(f"matrix_skills_msg:{room_id}", {"event_id": event_id})


async def handle_skill_toggle(
    client, room_id: str, reaction_event, store, platform: PlatformClient, dispatcher,
) -> None:
    """Toggle a skill based on numbered reaction, then edit the skills message."""
    key = reaction_event.key
    if key not in SKILL_REACTIONS:
        return
    skill_index = SKILL_REACTIONS.index(key)
    if skill_index >= len(_SKILL_NAMES_ORDER):
        return

    skill_name = _SKILL_NAMES_ORDER[skill_index]
    room_meta = await get_room_meta(store, room_id)
    if room_meta is None:
        return

    matrix_user_id = room_meta["matrix_user_id"]
    user_meta = await get_user_meta(store, matrix_user_id) or {}
    platform_user_id = user_meta.get("platform_user_id", matrix_user_id)

    settings = await platform.get_settings(platform_user_id)
    current = settings.skills.get(skill_name, False)
    action = SettingsAction(action="toggle_skill",
                            payload={"skill": skill_name, "enabled": not current})
    await platform.update_settings(platform_user_id, action)

    updated = await platform.get_settings(platform_user_id)
    new_body = build_skills_text(updated)

    msg_data = await store.get(f"matrix_skills_msg:{room_id}")
    if msg_data:
        await edit_message(client, room_id, msg_data["event_id"], new_body)
    else:
        await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": new_body})


async def handle_text_setting(
    client, room_id: str, matrix_user_id: str,
    command: str, args: list[str], store, platform: PlatformClient,
) -> None:
    """Handle !connectors, !soul, !safety, !plan, !status, !whoami."""
    user_meta = await get_user_meta(store, matrix_user_id) or {}
    platform_user_id = user_meta.get("platform_user_id", matrix_user_id)

    if command == "whoami":
        name = user_meta.get("display_name") or matrix_user_id
        body = f"Аккаунт: {matrix_user_id}\nПлатформа: {platform_user_id}\nИмя: {name}"

    elif command == "status":
        body = f"Статус платформы: ✅ доступна\nАккаунт: {matrix_user_id}"

    elif command == "plan":
        settings = await platform.get_settings(platform_user_id)
        plan = settings.plan
        name_plan = plan.get("name", "Beta")
        used = plan.get("tokens_used", 0)
        limit = plan.get("tokens_limit", 1000)
        pct = used * 10 // limit if limit else 0
        bar = "━" * pct + "░" * (10 - pct)
        body = f"Подписка: {name_plan}\nТокены: {used} / {limit}\n{bar} {used * 100 // limit if limit else 0}%"

    elif command == "soul":
        if len(args) >= 2:
            field, value = args[0], " ".join(args[1:])
            await platform.update_settings(platform_user_id,
                                           SettingsAction(action="set_soul",
                                                          payload={"field": field, "value": value}))
            body = f"✅ soul.{field} = «{value}»"
        else:
            settings = await platform.get_settings(platform_user_id)
            lines = [f"{k}: {v}" for k, v in settings.soul.items()] if settings.soul else ["(пусто)"]
            body = "Soul:\n" + "\n".join(lines)

    elif command == "safety":
        if args and args[0] in ("on", "off"):
            enabled = args[0] == "on"
            trigger = " ".join(args[1:])
            await platform.update_settings(platform_user_id,
                                           SettingsAction(action="set_safety",
                                                          payload={"trigger": trigger, "enabled": enabled}))
            body = f"✅ safety.{trigger} = {'включено' if enabled else 'выключено'}"
        else:
            settings = await platform.get_settings(platform_user_id)
            lines = [f"{'✅' if v else '❌'} {k}" for k, v in settings.safety.items()]
            body = "Безопасность:\n" + ("\n".join(lines) if lines else "(пусто)")

    elif command == "connectors":
        settings = await platform.get_settings(platform_user_id)
        if settings.connectors:
            lines = [f"✅ {k}" for k in settings.connectors]
            body = "Коннекторы:\n" + "\n".join(lines)
        else:
            body = "Коннекторы:\n❌ Нет подключённых сервисов\n\n!connect gmail — подключить Gmail"

    else:
        body = f"Неизвестная команда: !{command}"

    await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body})
  • Step 4: Run — expect all PASS
pytest tests/adapter/matrix/test_settings_handler.py -v
  • Step 5: Commit
git add adapter/matrix/handlers/settings.py tests/adapter/matrix/test_settings_handler.py
git commit -m "feat(matrix): settings handler — !skills m.replace + commands"

Task 8: Bot entry point — sync loop + event routing

Files:

  • Create: adapter/matrix/bot.py

  • Create: tests/adapter/matrix/test_bot.py

  • Step 1: Write failing tests

# tests/adapter/matrix/test_bot.py
import pytest
from types import SimpleNamespace
from unittest.mock import AsyncMock
from core.store import InMemoryStore
from sdk.mock import MockPlatformClient
from adapter.matrix.bot import create_dispatcher, route_message_event, route_reaction_event
from adapter.matrix.store import set_room_meta, set_room_state, set_user_meta
from core.auth import AuthManager
from core.handler import EventDispatcher


@pytest.fixture
def store():
    return InMemoryStore()


@pytest.fixture
def platform():
    return MockPlatformClient()


@pytest.fixture
def dispatcher(platform, store):
    return create_dispatcher(platform, store)


@pytest.fixture
def client():
    c = AsyncMock()
    c.user_id = "@bot:m.org"
    c.room_create = AsyncMock(return_value=AsyncMock(room_id="!new:m.org"))
    c.room_invite = AsyncMock()
    c.room_put_state = AsyncMock()
    return c


async def _setup(store, platform, room_id="!dm:m.org", uid="@alice:m.org"):
    user = await platform.get_or_create_user(uid, "matrix", "Alice")
    await set_user_meta(store, uid, {
        "platform_user_id": user.user_id, "display_name": "Alice",
        "space_id": None, "settings_room_id": None, "next_chat_index": 2,
    })
    await set_room_meta(store, room_id, {
        "room_type": "chat", "chat_id": "C1",
        "display_name": "Чат 1", "matrix_user_id": uid,
    })
    await set_room_state(store, room_id, "idle")
    await AuthManager(platform, store).confirm(uid)


async def test_create_dispatcher_returns_event_dispatcher(platform, store):
    d = create_dispatcher(platform, store)
    assert isinstance(d, EventDispatcher)


async def test_route_text_message(client, store, platform, dispatcher):
    await _setup(store, platform)
    event = SimpleNamespace(sender="@alice:m.org", body="Hello", event_id="$e1",
                            msgtype="m.text", replyto_event_id=None)
    room = SimpleNamespace(room_id="!dm:m.org")
    await route_message_event(client, room, event, store, platform, dispatcher)
    client.room_send.assert_called()
    body_calls = [str(c) for c in client.room_send.call_args_list]
    assert any("[MOCK]" in c for c in body_calls)


async def test_route_new_command(client, store, platform, dispatcher):
    await _setup(store, platform)
    event = SimpleNamespace(sender="@alice:m.org", body="!new Test", event_id="$e2",
                            msgtype="m.text", replyto_event_id=None)
    room = SimpleNamespace(room_id="!dm:m.org")
    await route_message_event(client, room, event, store, platform, dispatcher)
    client.room_create.assert_called()


async def test_route_skills_command(client, store, platform, dispatcher):
    await _setup(store, platform)
    event = SimpleNamespace(sender="@alice:m.org", body="!skills", event_id="$e3",
                            msgtype="m.text", replyto_event_id=None)
    room = SimpleNamespace(room_id="!dm:m.org")
    await route_message_event(client, room, event, store, platform, dispatcher)
    body = client.room_send.call_args[0][2]["body"]
    assert "web-search" in body


async def test_bot_ignores_own_messages(client, store, platform, dispatcher):
    await _setup(store, platform)
    event = SimpleNamespace(sender="@bot:m.org", body="Hello", event_id="$e4",
                            msgtype="m.text", replyto_event_id=None)
    room = SimpleNamespace(room_id="!dm:m.org")
    await route_message_event(client, room, event, store, platform, dispatcher)
    client.room_send.assert_not_called()


async def test_route_confirm_reaction(client, store, platform, dispatcher):
    await _setup(store, platform)
    await set_room_state(store, "!dm:m.org", "confirm_pending")
    event = SimpleNamespace(sender="@alice:m.org", key="👍",
                            reacted_to_id="$orig", event_id="$r1",
                            source={"content": {"m.relates_to": {"key": "👍", "event_id": "$orig"}}})
    room = SimpleNamespace(room_id="!dm:m.org")
    await route_reaction_event(client, room, event, store, platform, dispatcher)
    client.room_send.assert_called()
  • Step 2: Run — expect ImportError
pytest tests/adapter/matrix/test_bot.py -v
  • Step 3: Implement bot.py
# adapter/matrix/bot.py
from __future__ import annotations
import os
import structlog
from nio import AsyncClient, InviteMemberEvent, RoomMessageText, UnknownEvent
from adapter.matrix.converter import CONFIRM_REACTIONS, SKILL_REACTIONS
from adapter.matrix.handlers.auth import handle_invite
from adapter.matrix.handlers.chat import handle_list_chats, handle_message, handle_new_chat
from adapter.matrix.handlers.confirm import handle_confirm_callback
from adapter.matrix.handlers.settings import handle_skill_toggle, handle_skills, handle_text_setting
from adapter.matrix.store import get_room_meta, get_room_state
from core.auth import AuthManager
from core.chat import ChatManager
from core.handler import EventDispatcher
from core.handlers import register_all
from core.settings import SettingsManager
from core.store import SQLiteStore
from sdk.interface import PlatformClient
from sdk.mock import MockPlatformClient

logger = structlog.get_logger(__name__)

_SETTINGS_COMMANDS = {"connectors", "soul", "safety", "plan", "status", "whoami"}


def create_dispatcher(platform: PlatformClient, store) -> EventDispatcher:
    d = EventDispatcher(
        platform=platform,
        chat_mgr=ChatManager(platform, store),
        auth_mgr=AuthManager(platform, store),
        settings_mgr=SettingsManager(platform, store),
    )
    register_all(d)
    return d


async def route_message_event(client, room, event, store, platform, dispatcher) -> None:
    room_id = room.room_id
    sender = event.sender
    if sender == client.user_id:
        return

    room_meta = await get_room_meta(store, room_id)
    if room_meta is None:
        return

    body: str = event.body or ""
    state = await get_room_state(store, room_id)

    if state == "confirm_pending" and body.startswith("!") and body[1:].split()[0] in ("yes", "no"):
        await handle_confirm_callback(client, room_id, event, store, platform, dispatcher, is_reaction=False)
        return

    if body.startswith("!"):
        parts = body[1:].split(maxsplit=1)
        cmd = parts[0].lower()
        args = parts[1].split() if len(parts) > 1 else []

        if cmd == "new":
            await handle_new_chat(client, room_id, event, store, platform, dispatcher)
        elif cmd == "chats":
            await handle_list_chats(client, room_id, sender, store)
        elif cmd == "skills":
            await handle_skills(client, room_id, sender, store, platform, dispatcher)
        elif cmd in _SETTINGS_COMMANDS:
            await handle_text_setting(client, room_id, sender, cmd, args, store, platform)
        else:
            # Unknown command — treat as regular message
            await handle_message(client, room_id, event, store, platform, dispatcher)
    else:
        await handle_message(client, room_id, event, store, platform, dispatcher)


async def route_reaction_event(client, room, event, store, platform, dispatcher) -> None:
    room_id = room.room_id
    sender = getattr(event, "sender", None)
    if sender == client.user_id:
        return

    # nio may give us a ReactionEvent or UnknownEvent; normalise key access
    key = getattr(event, "key", None)
    reacted_to_id = getattr(event, "reacted_to_id", None)
    if key is None:
        relates = event.source.get("content", {}).get("m.relates_to", {})
        key = relates.get("key", "")
        reacted_to_id = relates.get("event_id", "")

    from types import SimpleNamespace
    norm = SimpleNamespace(sender=sender, key=key, reacted_to_id=reacted_to_id,
                           event_id=event.event_id)

    state = await get_room_state(store, room_id)
    if state == "confirm_pending" and key in CONFIRM_REACTIONS:
        await handle_confirm_callback(client, room_id, norm, store, platform, dispatcher, is_reaction=True)
    elif key in SKILL_REACTIONS:
        await handle_skill_toggle(client, room_id, norm, store, platform, dispatcher)


async def main() -> None:
    homeserver = os.getenv("MATRIX_HOMESERVER", "https://matrix.org")
    user_id = os.getenv("MATRIX_USER_ID", "@lambda-bot:matrix.org")
    password = os.getenv("MATRIX_PASSWORD", "")

    store = SQLiteStore("matrix_bot.db")
    platform = MockPlatformClient()
    dispatcher = create_dispatcher(platform, store)

    client = AsyncClient(homeserver, user_id)
    await client.login(password)
    logger.info("Logged in", user_id=user_id)

    async def on_message(room, event: RoomMessageText) -> None:
        await route_message_event(client, room, event, store, platform, dispatcher)

    async def on_invite(room, event: InviteMemberEvent) -> None:
        if event.membership == "invite" and event.state_key == client.user_id:
            display_name = getattr(event, "display_name", None)
            await handle_invite(client, room.room_id, event.sender, store, platform, display_name)

    async def on_unknown(room, event: UnknownEvent) -> None:
        if event.type == "m.reaction":
            await route_reaction_event(client, room, event, store, platform, dispatcher)

    client.add_event_callback(on_message, RoomMessageText)
    client.add_event_callback(on_invite, InviteMemberEvent)
    client.add_event_callback(on_unknown, UnknownEvent)

    logger.info("Starting sync loop")
    await client.sync_forever(timeout=30000)


if __name__ == "__main__":
    import asyncio
    asyncio.run(main())
  • Step 4: Run matrix tests
pytest tests/adapter/matrix/ -v

Expected: all PASS.

  • Step 5: Run full suite — verify no regressions
pytest tests/ -v

Expected: all tests PASS including pre-existing tests/core/ and tests/platform/.

  • Step 6: Commit
git add adapter/matrix/bot.py tests/adapter/matrix/test_bot.py
git commit -m "feat(matrix): bot entry point — sync loop and event routing"