- 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
1681 lines
57 KiB
Markdown
1681 lines
57 KiB
Markdown
# 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**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```bash
|
||
cd /path/to/surfaces-bot && pytest tests/adapter/matrix/test_store.py -v
|
||
```
|
||
|
||
- [ ] **Step 3: Create `__init__.py` files**
|
||
|
||
```bash
|
||
touch adapter/__init__.py adapter/matrix/__init__.py tests/adapter/__init__.py tests/adapter/matrix/__init__.py
|
||
```
|
||
|
||
- [ ] **Step 4: Implement store.py**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```bash
|
||
pytest tests/adapter/matrix/test_store.py -v
|
||
```
|
||
Expected: 6 tests PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```bash
|
||
pytest tests/adapter/matrix/test_converter.py -v
|
||
```
|
||
|
||
- [ ] **Step 3: Implement converter.py**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```bash
|
||
pytest tests/adapter/matrix/test_converter.py -v
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```bash
|
||
pytest tests/adapter/matrix/test_reactions.py -v
|
||
```
|
||
|
||
- [ ] **Step 3: Implement reactions.py**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```bash
|
||
pytest tests/adapter/matrix/test_reactions.py -v
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```bash
|
||
pytest tests/adapter/matrix/test_auth.py -v
|
||
```
|
||
|
||
- [ ] **Step 3: Create `__init__.py` and implement auth.py**
|
||
|
||
```python
|
||
# adapter/matrix/handlers/__init__.py
|
||
# (empty)
|
||
```
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```bash
|
||
pytest tests/adapter/matrix/test_auth.py -v
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```bash
|
||
pytest tests/adapter/matrix/test_chat_handler.py -v
|
||
```
|
||
|
||
- [ ] **Step 3: Implement handlers/chat.py**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```bash
|
||
pytest tests/adapter/matrix/test_chat_handler.py -v
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```bash
|
||
pytest tests/adapter/matrix/test_confirm.py -v
|
||
```
|
||
|
||
- [ ] **Step 3: Implement handlers/confirm.py**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```bash
|
||
pytest tests/adapter/matrix/test_confirm.py -v
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```bash
|
||
pytest tests/adapter/matrix/test_settings_handler.py -v
|
||
```
|
||
|
||
- [ ] **Step 3: Implement handlers/settings.py**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```bash
|
||
pytest tests/adapter/matrix/test_settings_handler.py -v
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```bash
|
||
pytest tests/adapter/matrix/test_bot.py -v
|
||
```
|
||
|
||
- [ ] **Step 3: Implement bot.py**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```bash
|
||
pytest tests/adapter/matrix/ -v
|
||
```
|
||
Expected: all PASS.
|
||
|
||
- [ ] **Step 5: Run full suite — verify no regressions**
|
||
|
||
```bash
|
||
pytest tests/ -v
|
||
```
|
||
Expected: all tests PASS including pre-existing `tests/core/` and `tests/platform/`.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
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"
|
||
```
|