- 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
57 KiB
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 ofidle | waiting_response | confirm_pending | settings_activematrix_skills_msg:{room_id}→{event_id}— event_id of the last!skillsmessage (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__.pyfiles
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__.pyand 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"