feat(matrix): add adapter baseline and platform-aware command hints
This commit is contained in:
parent
bcdaea5143
commit
82eb711844
20 changed files with 1127 additions and 3 deletions
0
tests/adapter/__init__.py
Normal file
0
tests/adapter/__init__.py
Normal file
1
tests/adapter/matrix/__init__.py
Normal file
1
tests/adapter/matrix/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from __future__ import annotations
|
||||
109
tests/adapter/matrix/test_converter.py
Normal file
109
tests/adapter/matrix/test_converter.py
Normal 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
|
||||
94
tests/adapter/matrix/test_dispatcher.py
Normal file
94
tests/adapter/matrix/test_dispatcher.py
Normal 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()
|
||||
33
tests/adapter/matrix/test_reactions.py
Normal file
33
tests/adapter/matrix/test_reactions.py
Normal 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
|
||||
72
tests/adapter/matrix/test_store.py
Normal file
72
tests/adapter/matrix/test_store.py
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue