feat(matrix): add adapter baseline and platform-aware command hints

This commit is contained in:
Mikhail Putilovskij 2026-04-01 01:04:54 +03:00
parent bcdaea5143
commit 82eb711844
20 changed files with 1127 additions and 3 deletions

View file

View file

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

View file

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

View file

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

View file

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

View file

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