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

2
adapter/__init__.py Normal file
View file

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

View file

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

215
adapter/matrix/bot.py Normal file
View file

@ -0,0 +1,215 @@
from __future__ import annotations
import asyncio
import os
from dataclasses import dataclass
from pathlib import Path
import structlog
from nio import (
AsyncClient,
InviteMemberEvent,
MatrixRoom,
ReactionEvent,
RoomMemberEvent,
RoomMessageText,
)
from dotenv import load_dotenv
from adapter.matrix.converter import from_reaction, from_room_event
from adapter.matrix.handlers import register_matrix_handlers
from adapter.matrix.handlers.auth import handle_invite
from adapter.matrix.room_router import resolve_chat_id
from core.auth import AuthManager
from core.chat import ChatManager
from core.handler import EventDispatcher
from core.handlers import register_all
from core.protocol import (
OutgoingEvent,
OutgoingMessage,
OutgoingNotification,
OutgoingTyping,
OutgoingUI,
)
from core.settings import SettingsManager
from core.store import InMemoryStore, SQLiteStore, StateStore
from sdk.mock import MockPlatformClient
logger = structlog.get_logger(__name__)
load_dotenv(Path(__file__).resolve().parents[2] / ".env")
@dataclass
class MatrixRuntime:
platform: MockPlatformClient
store: StateStore
chat_mgr: ChatManager
auth_mgr: AuthManager
settings_mgr: SettingsManager
dispatcher: EventDispatcher
def build_event_dispatcher(platform: MockPlatformClient, store: StateStore) -> EventDispatcher:
chat_mgr = ChatManager(platform, store)
auth_mgr = AuthManager(platform, store)
settings_mgr = SettingsManager(platform, store)
dispatcher = EventDispatcher(
platform=platform, chat_mgr=chat_mgr, auth_mgr=auth_mgr, settings_mgr=settings_mgr
)
register_all(dispatcher)
register_matrix_handlers(dispatcher)
return dispatcher
def build_runtime(
platform: MockPlatformClient | None = None, store: StateStore | None = None
) -> MatrixRuntime:
platform = platform or MockPlatformClient()
store = store or InMemoryStore()
chat_mgr = ChatManager(platform, store)
auth_mgr = AuthManager(platform, store)
settings_mgr = SettingsManager(platform, store)
dispatcher = EventDispatcher(
platform=platform, chat_mgr=chat_mgr, auth_mgr=auth_mgr, settings_mgr=settings_mgr
)
register_all(dispatcher)
register_matrix_handlers(dispatcher)
return MatrixRuntime(
platform=platform,
store=store,
chat_mgr=chat_mgr,
auth_mgr=auth_mgr,
settings_mgr=settings_mgr,
dispatcher=dispatcher,
)
class MatrixBot:
def __init__(self, client: AsyncClient, runtime: MatrixRuntime) -> None:
self.client = client
self.runtime = runtime
async def on_room_message(self, room: MatrixRoom, event: RoomMessageText) -> None:
if getattr(event, "sender", None) == self.client.user_id:
return
chat_id = await resolve_chat_id(self.runtime.store, room.room_id, event.sender)
incoming = from_room_event(event, room_id=room.room_id, chat_id=chat_id)
if incoming is None:
return
outgoing = await self.runtime.dispatcher.dispatch(incoming)
await self._send_all(room.room_id, outgoing)
async def on_reaction(self, room: MatrixRoom, event: ReactionEvent) -> None:
if getattr(event, "sender", None) == self.client.user_id:
return
chat_id = await resolve_chat_id(self.runtime.store, room.room_id, event.sender)
incoming = from_reaction(event, sender=event.sender, chat_id=chat_id)
if incoming is None:
return
outgoing = await self.runtime.dispatcher.dispatch(incoming)
await self._send_all(room.room_id, outgoing)
async def on_member(self, room: MatrixRoom, event: RoomMemberEvent) -> None:
if getattr(event, "sender", None) == self.client.user_id:
return
membership = getattr(event, "membership", None)
if membership == "invite":
await handle_invite(
self.client,
room,
event,
self.runtime.platform,
self.runtime.store,
self.runtime.auth_mgr,
)
async def _send_all(self, room_id: str, outgoing: list[OutgoingEvent]) -> None:
for event in outgoing:
await send_outgoing(self.client, room_id, event)
def _button_action_to_reaction(action: str) -> str | None:
if action in {"confirm", "ok", "accept"}:
return "👍"
if action in {"cancel", "reject", "deny"}:
return ""
return None
async def send_outgoing(client: AsyncClient, room_id: str, event: OutgoingEvent) -> None:
if isinstance(event, OutgoingTyping):
await client.room_typing(room_id, event.is_typing, timeout=25000)
return
if isinstance(event, OutgoingNotification):
body = f"[{event.level.upper()}] {event.text}"
await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body})
return
if isinstance(event, OutgoingMessage):
await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": event.text})
return
if isinstance(event, OutgoingUI):
body = event.text
buttons = []
for button in event.buttons:
buttons.append(f"{button.label}")
if buttons:
body = "\n".join([body, "", *buttons])
resp = await client.room_send(
room_id, "m.room.message", {"msgtype": "m.text", "body": body}
)
event_id = getattr(resp, "event_id", None)
if event_id:
for button in event.buttons:
reaction = _button_action_to_reaction(button.action)
if reaction:
await client.room_send(
room_id,
"m.reaction",
{
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": event_id,
"key": reaction,
}
},
)
return
async def main() -> None:
homeserver = os.environ.get("MATRIX_HOMESERVER")
user_id = os.environ.get("MATRIX_USER_ID")
device_id = os.environ.get("MATRIX_DEVICE_ID", "")
password = os.environ.get("MATRIX_PASSWORD")
token = os.environ.get("MATRIX_ACCESS_TOKEN")
db_path = os.environ.get("MATRIX_DB_PATH", "lambda_matrix.db")
if not homeserver or not user_id:
raise RuntimeError("MATRIX_HOMESERVER and MATRIX_USER_ID are required")
runtime = build_runtime(store=SQLiteStore(db_path))
client = AsyncClient(
homeserver,
user=user_id,
device_id=device_id,
store_path=os.environ.get("MATRIX_STORE_PATH"),
)
if token:
client.access_token = token
elif password:
await client.login(password=password, device_name="surfaces-bot")
bot = MatrixBot(client, runtime)
client.add_event_callback(bot.on_room_message, RoomMessageText)
client.add_event_callback(bot.on_reaction, ReactionEvent)
client.add_event_callback(bot.on_member, (InviteMemberEvent, RoomMemberEvent))
logger.info("Matrix bot starting")
try:
await client.sync_forever(timeout=30000)
finally:
await client.close()
if __name__ == "__main__":
asyncio.run(main())

143
adapter/matrix/converter.py Normal file
View file

@ -0,0 +1,143 @@
from __future__ import annotations
from typing import Any
from adapter.matrix.reactions import CANCEL_REACTION, CONFIRM_REACTION, reaction_to_skill_index
from core.protocol import (
Attachment,
IncomingCallback,
IncomingCommand,
IncomingEvent,
IncomingMessage,
)
PLATFORM = "matrix"
def extract_attachments(event: Any) -> list[Attachment]:
msgtype = getattr(event, "msgtype", None)
if msgtype is None:
content = getattr(event, "content", {}) or {}
msgtype = content.get("msgtype")
if msgtype == "m.image":
return [
Attachment(
type="image",
url=getattr(event, "url", None),
mime_type=getattr(event, "mimetype", None),
)
]
if msgtype == "m.file":
return [
Attachment(
type="document",
url=getattr(event, "url", None),
filename=getattr(event, "body", None),
mime_type=getattr(event, "mimetype", None),
)
]
if msgtype == "m.audio":
return [
Attachment(
type="audio",
url=getattr(event, "url", None),
mime_type=getattr(event, "mimetype", None),
)
]
if msgtype == "m.video":
return [
Attachment(
type="video",
url=getattr(event, "url", None),
mime_type=getattr(event, "mimetype", None),
)
]
return []
def from_command(body: str, sender: str, chat_id: str) -> IncomingEvent:
raw = body.lstrip("!").strip()
parts = raw.split()
command = parts[0].lower() if parts else ""
args = parts[1:]
if command in {"yes", "no"}:
action = "confirm" if command == "yes" else "cancel"
return IncomingCallback(
user_id=sender,
platform=PLATFORM,
chat_id=chat_id,
action=action,
payload={"source": "command", "command": command},
)
aliases = {
"skills": "settings_skills",
"connectors": "settings_connectors",
"soul": "settings_soul",
"safety": "settings_safety",
"plan": "settings_plan",
"status": "settings_status",
"whoami": "settings_whoami",
}
command = aliases.get(command, command)
return IncomingCommand(
user_id=sender,
platform=PLATFORM,
chat_id=chat_id,
command=command,
args=args,
)
def from_reaction(event: Any, sender: str, chat_id: str) -> IncomingCallback | None:
content = getattr(event, "content", {}) or {}
relates_to = content.get("m.relates_to", {})
key = getattr(event, "key", None) or relates_to.get("key")
event_id = getattr(event, "event_id", None) or relates_to.get("event_id")
if not key:
return None
if key == CONFIRM_REACTION:
return IncomingCallback(
user_id=sender,
platform=PLATFORM,
chat_id=chat_id,
action="confirm",
payload={"event_id": event_id, "reaction": key},
)
if key == CANCEL_REACTION:
return IncomingCallback(
user_id=sender,
platform=PLATFORM,
chat_id=chat_id,
action="cancel",
payload={"event_id": event_id, "reaction": key},
)
skill_index = reaction_to_skill_index(key)
if skill_index is not None:
return IncomingCallback(
user_id=sender,
platform=PLATFORM,
chat_id=chat_id,
action="toggle_skill",
payload={"event_id": event_id, "reaction": key, "skill_index": skill_index},
)
return None
def from_room_event(event: Any, room_id: str, chat_id: str) -> IncomingEvent | None:
body = (getattr(event, "body", None) or "").strip()
sender = getattr(event, "sender", "")
if body.startswith("!"):
return from_command(body, sender=sender, chat_id=chat_id)
return IncomingMessage(
user_id=sender,
platform=PLATFORM,
chat_id=chat_id,
text=body,
attachments=extract_attachments(event),
reply_to=getattr(event, "replyto_event_id", None),
)

View file

@ -0,0 +1,41 @@
from __future__ import annotations
from adapter.matrix.handlers.chat import (
handle_archive,
handle_list_chats,
handle_new_chat,
handle_rename,
)
from adapter.matrix.handlers.confirm import handle_cancel, handle_confirm
from adapter.matrix.handlers.settings import (
handle_settings,
handle_settings_connectors,
handle_settings_plan,
handle_settings_safety,
handle_settings_skills,
handle_settings_soul,
handle_settings_status,
handle_settings_whoami,
handle_toggle_skill,
)
from core.handler import EventDispatcher
from core.protocol import IncomingCallback, IncomingCommand
def register_matrix_handlers(dispatcher: EventDispatcher) -> None:
dispatcher.register(IncomingCommand, "new", handle_new_chat)
dispatcher.register(IncomingCommand, "chats", handle_list_chats)
dispatcher.register(IncomingCommand, "rename", handle_rename)
dispatcher.register(IncomingCommand, "archive", handle_archive)
dispatcher.register(IncomingCommand, "settings", handle_settings)
dispatcher.register(IncomingCommand, "settings_skills", handle_settings_skills)
dispatcher.register(IncomingCommand, "settings_connectors", handle_settings_connectors)
dispatcher.register(IncomingCommand, "settings_soul", handle_settings_soul)
dispatcher.register(IncomingCommand, "settings_safety", handle_settings_safety)
dispatcher.register(IncomingCommand, "settings_plan", handle_settings_plan)
dispatcher.register(IncomingCommand, "settings_status", handle_settings_status)
dispatcher.register(IncomingCommand, "settings_whoami", handle_settings_whoami)
dispatcher.register(IncomingCallback, "confirm", handle_confirm)
dispatcher.register(IncomingCallback, "cancel", handle_cancel)
dispatcher.register(IncomingCallback, "toggle_skill", handle_toggle_skill)

View file

@ -0,0 +1,34 @@
from __future__ import annotations
from typing import Any
from adapter.matrix.store import get_room_meta, set_room_meta
async def handle_invite(client: Any, room: Any, event: Any, platform, store, auth_mgr) -> None:
existing = await get_room_meta(store, room.room_id)
if existing is not None:
return
user = await platform.get_or_create_user(
external_id=getattr(event, "sender", ""),
platform="matrix",
display_name=getattr(room, "display_name", None),
)
await auth_mgr.confirm(getattr(event, "sender", ""))
await client.join(room.room_id)
await set_room_meta(
store,
room.room_id,
{
"room_type": "chat",
"chat_id": "C1",
"display_name": getattr(room, "display_name", room.room_id),
"matrix_user_id": getattr(event, "sender", user.external_id),
},
)
message = (
f"Привет, {user.display_name or user.external_id}! Пиши — я здесь.\n\n"
f"Команды: !new · !chats · !rename · !archive · !skills"
)
await client.room_send(room.room_id, "m.room.message", {"msgtype": "m.text", "body": message})

View file

@ -0,0 +1,50 @@
from __future__ import annotations
from core.protocol import IncomingCommand, OutgoingMessage
async def handle_new_chat(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
if not await auth_mgr.is_authenticated(event.user_id):
return [OutgoingMessage(chat_id=event.chat_id, text="Введите !start чтобы начать.")]
name = " ".join(event.args).strip() if event.args else ""
chats = await chat_mgr.list_active(event.user_id)
chat_id = f"C{len(chats) + 1}"
ctx = await chat_mgr.get_or_create(
user_id=event.user_id,
chat_id=chat_id,
platform=event.platform,
surface_ref=event.chat_id,
name=name or None,
)
return [
OutgoingMessage(
chat_id=event.chat_id, text=f"Создан чат: {ctx.display_name} ({ctx.chat_id})"
)
]
async def handle_list_chats(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
chats = await chat_mgr.list_active(event.user_id)
if not chats:
return [OutgoingMessage(chat_id=event.chat_id, text="Нет активных чатов.")]
lines = [f"{c.display_name} ({c.chat_id})" for c in chats]
return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))]
async def handle_rename(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
if not event.args:
return [OutgoingMessage(chat_id=event.chat_id, text="Укажите название: !rename Название")]
ctx = await chat_mgr.rename(event.chat_id, " ".join(event.args), user_id=event.user_id)
return [OutgoingMessage(chat_id=event.chat_id, text=f"Переименован в: {ctx.display_name}")]
async def handle_archive(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
await chat_mgr.archive(event.chat_id, user_id=event.user_id)
return [OutgoingMessage(chat_id=event.chat_id, text="Чат архивирован.")]

View file

@ -0,0 +1,19 @@
from __future__ import annotations
from core.protocol import IncomingCallback, OutgoingMessage
async def handle_confirm(
event: IncomingCallback, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
action_id = event.payload.get("action_id", "unknown")
return [
OutgoingMessage(chat_id=event.chat_id, text=f"Действие подтверждено (id: {action_id}).")
]
async def handle_cancel(
event: IncomingCallback, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
action_id = event.payload.get("action_id", "unknown")
return [OutgoingMessage(chat_id=event.chat_id, text=f"Действие отменено (id: {action_id}).")]

View file

@ -0,0 +1,145 @@
from __future__ import annotations
from adapter.matrix.reactions import build_skills_text
from core.protocol import IncomingCommand, OutgoingMessage, SettingsAction
def _render_mapping(title: str, data: dict | None) -> str:
data = data or {}
lines = [title]
if not data:
lines.append("Нет данных.")
else:
for key, value in data.items():
lines.append(f"{key}: {value}")
return "\n".join(lines)
def _parse_bool(value: str) -> bool:
return value.lower() in {"1", "true", "yes", "on", "enable", "enabled"}
async def handle_settings(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
return [
OutgoingMessage(
chat_id=event.chat_id,
text=(
"⚙️ Настройки Matrix\n"
"!skills\n"
"!connectors\n"
"!soul [field value]\n"
"!safety [trigger on|off]\n"
"!plan\n"
"!status\n"
"!whoami"
),
)
]
async def handle_settings_skills(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
settings = await settings_mgr.get(event.user_id)
return [OutgoingMessage(chat_id=event.chat_id, text=build_skills_text(settings))]
async def handle_settings_connectors(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
settings = await settings_mgr.get(event.user_id)
return [
OutgoingMessage(
chat_id=event.chat_id, text=_render_mapping("🔗 Коннекторы", settings.connectors)
)
]
async def handle_settings_soul(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
if len(event.args) >= 2:
field = event.args[0]
value = " ".join(event.args[1:])
await settings_mgr.apply(
event.user_id,
SettingsAction(action="set_soul", payload={"field": field, "value": value}),
)
return [
OutgoingMessage(chat_id=event.chat_id, text=f"Личность обновлена: {field} = {value}")
]
settings = await settings_mgr.get(event.user_id)
return [
OutgoingMessage(chat_id=event.chat_id, text=_render_mapping("🧠 Личность", settings.soul))
]
async def handle_settings_safety(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
if len(event.args) >= 2:
trigger = event.args[0]
enabled = _parse_bool(event.args[1])
await settings_mgr.apply(
event.user_id,
SettingsAction(action="set_safety", payload={"trigger": trigger, "enabled": enabled}),
)
state = "включена" if enabled else "выключена"
return [OutgoingMessage(chat_id=event.chat_id, text=f"Безопасность {trigger} {state}")]
settings = await settings_mgr.get(event.user_id)
return [
OutgoingMessage(
chat_id=event.chat_id, text=_render_mapping("🔒 Безопасность", settings.safety)
)
]
async def handle_settings_plan(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
settings = await settings_mgr.get(event.user_id)
return [OutgoingMessage(chat_id=event.chat_id, text=_render_mapping("💳 План", settings.plan))]
async def handle_settings_status(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
chats = await chat_mgr.list_active(event.user_id)
settings = await settings_mgr.get(event.user_id)
text = "\n".join(
[
"📊 Статус",
f"Активных чатов: {len(chats)}",
f"Скиллов: {len(settings.skills)}",
f"Коннекторов: {len(settings.connectors)}",
]
)
return [OutgoingMessage(chat_id=event.chat_id, text=text)]
async def handle_settings_whoami(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
return [OutgoingMessage(chat_id=event.chat_id, text=f"👤 {event.platform}:{event.user_id}")]
async def handle_toggle_skill(event, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
settings = await settings_mgr.get(event.user_id)
keys = list(settings.skills.keys())
skill = event.payload.get("skill")
if not skill:
idx = event.payload.get("skill_index")
if isinstance(idx, int) and 1 <= idx <= len(keys):
skill = keys[idx - 1]
if not skill:
return [OutgoingMessage(chat_id=event.chat_id, text="Ошибка: не удалось определить навык.")]
enabled = not bool(settings.skills.get(skill, False))
await settings_mgr.apply(
event.user_id,
SettingsAction(action="toggle_skill", payload={"skill": skill, "enabled": enabled}),
)
state = "включён" if enabled else "выключен"
return [OutgoingMessage(chat_id=event.chat_id, text=f"Навык {skill} {state}.")]

View file

@ -0,0 +1,68 @@
from __future__ import annotations
from typing import Any
from nio import AsyncClient
from sdk.interface import UserSettings
CONFIRM_REACTION = "👍"
CANCEL_REACTION = ""
SKILL_REACTIONS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"]
REACTION_TO_INDEX = {emoji: idx + 1 for idx, emoji in enumerate(SKILL_REACTIONS)}
def build_skills_text(settings: UserSettings) -> str:
lines: list[str] = ["🧩 Скиллы"]
for idx, (name, enabled) in enumerate(settings.skills.items(), start=1):
state = "" if enabled else ""
emoji = SKILL_REACTIONS[idx - 1] if idx - 1 < len(SKILL_REACTIONS) else f"{idx}."
lines.append(f"{state} {emoji} {name}")
lines.append("")
lines.append("Реакции 1⃣-9⃣ переключают навыки.")
return "\n".join(lines)
def build_confirmation_text(description: str) -> str:
return "\n".join(
[
"🤖 Lambda",
description,
"",
f"{CONFIRM_REACTION} подтвердить · {CANCEL_REACTION} отменить",
"!yes — подтвердить · !no — отменить",
]
)
def reaction_to_skill_index(key: str) -> int | None:
return REACTION_TO_INDEX.get(key)
async def add_reaction(client: AsyncClient, room_id: str, event_id: str, key: str) -> Any:
return await client.room_send(
room_id,
"m.reaction",
{
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": event_id,
"key": key,
}
},
)
async def remove_reaction(client: AsyncClient, room_id: str, event_id: str, key: str) -> Any:
return await client.room_send(
room_id,
"m.reaction",
{
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": event_id,
"key": key,
},
"undo": True,
},
)

View file

@ -0,0 +1,23 @@
from __future__ import annotations
from adapter.matrix.store import get_room_meta, next_chat_id, set_room_meta
from core.store import StateStore
async def resolve_chat_id(store: StateStore, room_id: str, matrix_user_id: str) -> str:
meta = await get_room_meta(store, room_id)
if meta and meta.get("chat_id"):
return meta["chat_id"]
chat_id = await next_chat_id(store, matrix_user_id)
await set_room_meta(
store,
room_id,
{
"room_type": "chat",
"chat_id": chat_id,
"display_name": f"Чат {chat_id}",
"matrix_user_id": matrix_user_id,
},
)
return chat_id

50
adapter/matrix/store.py Normal file
View file

@ -0,0 +1,50 @@
from __future__ import annotations
from core.store import StateStore
ROOM_META_PREFIX = "matrix_room:"
USER_META_PREFIX = "matrix_user:"
ROOM_STATE_PREFIX = "matrix_state:"
SKILLS_MSG_PREFIX = "matrix_skills_msg:"
async def get_room_meta(store: StateStore, room_id: str) -> dict | None:
return await store.get(f"{ROOM_META_PREFIX}{room_id}")
async def set_room_meta(store: StateStore, room_id: str, meta: dict) -> None:
await store.set(f"{ROOM_META_PREFIX}{room_id}", meta)
async def get_user_meta(store: StateStore, matrix_user_id: str) -> dict | None:
return await store.get(f"{USER_META_PREFIX}{matrix_user_id}")
async def set_user_meta(store: StateStore, matrix_user_id: str, meta: dict) -> None:
await store.set(f"{USER_META_PREFIX}{matrix_user_id}", meta)
async def get_room_state(store: StateStore, room_id: str) -> str:
data = await store.get(f"{ROOM_STATE_PREFIX}{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"{ROOM_STATE_PREFIX}{room_id}", {"state": state})
async def get_skills_message_id(store: StateStore, room_id: str) -> str | None:
data = await store.get(f"{SKILLS_MSG_PREFIX}{room_id}")
return data["event_id"] if data else None
async def set_skills_message_id(store: StateStore, room_id: str, event_id: str) -> None:
await store.set(f"{SKILLS_MSG_PREFIX}{room_id}", {"event_id": event_id})
async def next_chat_id(store: StateStore, matrix_user_id: str) -> str:
meta = await get_user_meta(store, matrix_user_id) or {}
index = int(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}"

View file

@ -4,9 +4,19 @@ from __future__ import annotations
from core.protocol import IncomingCommand, OutgoingMessage from core.protocol import IncomingCommand, OutgoingMessage
def _command(platform: str, name: str) -> str:
prefix = "!" if platform == "matrix" else "/"
return f"{prefix}{name}"
async def handle_new_chat(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list: async def handle_new_chat(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
if not await auth_mgr.is_authenticated(event.user_id): if not await auth_mgr.is_authenticated(event.user_id):
return [OutgoingMessage(chat_id=event.chat_id, text="Введите /start чтобы начать.")] return [
OutgoingMessage(
chat_id=event.chat_id,
text=f"Введите {_command(event.platform, 'start')} чтобы начать.",
)
]
name = " ".join(event.args) if event.args else None name = " ".join(event.args) if event.args else None
ctx = await chat_mgr.get_or_create( ctx = await chat_mgr.get_or_create(
user_id=event.user_id, user_id=event.user_id,
@ -20,7 +30,12 @@ async def handle_new_chat(event: IncomingCommand, auth_mgr, platform, chat_mgr,
async def handle_rename(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list: async def handle_rename(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
if not event.args: if not event.args:
return [OutgoingMessage(chat_id=event.chat_id, text="Укажите название: /rename Название")] return [
OutgoingMessage(
chat_id=event.chat_id,
text=f"Укажите название: {_command(event.platform, 'rename')} Название",
)
]
ctx = await chat_mgr.rename(event.chat_id, " ".join(event.args)) ctx = await chat_mgr.rename(event.chat_id, " ".join(event.args))
return [OutgoingMessage(chat_id=event.chat_id, text=f"Переименован в: {ctx.display_name}")] return [OutgoingMessage(chat_id=event.chat_id, text=f"Переименован в: {ctx.display_name}")]

View file

@ -4,9 +4,18 @@ from __future__ import annotations
from core.protocol import IncomingMessage, OutgoingMessage, OutgoingTyping from core.protocol import IncomingMessage, OutgoingMessage, OutgoingTyping
def _start_command(platform: str) -> str:
return "!start" if platform == "matrix" else "/start"
async def handle_message(event: IncomingMessage, auth_mgr, platform, chat_mgr, settings_mgr) -> list: async def handle_message(event: IncomingMessage, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
if not await auth_mgr.is_authenticated(event.user_id): if not await auth_mgr.is_authenticated(event.user_id):
return [OutgoingMessage(chat_id=event.chat_id, text="Введите /start чтобы начать.")] return [
OutgoingMessage(
chat_id=event.chat_id,
text=f"Введите {_start_command(event.platform)} чтобы начать.",
)
]
# Voice slot fallback: audio attachment without registered voice_handler # Voice slot fallback: audio attachment without registered voice_handler
if event.attachments and event.attachments[0].type == "audio": if event.attachments and event.attachments[0].type == "audio":

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"