diff --git a/adapter/__init__.py b/adapter/__init__.py new file mode 100644 index 0000000..3ce55a6 --- /dev/null +++ b/adapter/__init__.py @@ -0,0 +1,2 @@ +from __future__ import annotations + diff --git a/adapter/matrix/__init__.py b/adapter/matrix/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/adapter/matrix/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/adapter/matrix/bot.py b/adapter/matrix/bot.py new file mode 100644 index 0000000..6ccebef --- /dev/null +++ b/adapter/matrix/bot.py @@ -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()) diff --git a/adapter/matrix/converter.py b/adapter/matrix/converter.py new file mode 100644 index 0000000..96a9f4e --- /dev/null +++ b/adapter/matrix/converter.py @@ -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), + ) diff --git a/adapter/matrix/handlers/__init__.py b/adapter/matrix/handlers/__init__.py new file mode 100644 index 0000000..61964e2 --- /dev/null +++ b/adapter/matrix/handlers/__init__.py @@ -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) diff --git a/adapter/matrix/handlers/auth.py b/adapter/matrix/handlers/auth.py new file mode 100644 index 0000000..bb8b332 --- /dev/null +++ b/adapter/matrix/handlers/auth.py @@ -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}) diff --git a/adapter/matrix/handlers/chat.py b/adapter/matrix/handlers/chat.py new file mode 100644 index 0000000..700b881 --- /dev/null +++ b/adapter/matrix/handlers/chat.py @@ -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="Чат архивирован.")] diff --git a/adapter/matrix/handlers/confirm.py b/adapter/matrix/handlers/confirm.py new file mode 100644 index 0000000..20e12f2 --- /dev/null +++ b/adapter/matrix/handlers/confirm.py @@ -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}).")] diff --git a/adapter/matrix/handlers/settings.py b/adapter/matrix/handlers/settings.py new file mode 100644 index 0000000..51fb61e --- /dev/null +++ b/adapter/matrix/handlers/settings.py @@ -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}.")] diff --git a/adapter/matrix/reactions.py b/adapter/matrix/reactions.py new file mode 100644 index 0000000..525a88d --- /dev/null +++ b/adapter/matrix/reactions.py @@ -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, + }, + ) diff --git a/adapter/matrix/room_router.py b/adapter/matrix/room_router.py new file mode 100644 index 0000000..f9c1a51 --- /dev/null +++ b/adapter/matrix/room_router.py @@ -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 diff --git a/adapter/matrix/store.py b/adapter/matrix/store.py new file mode 100644 index 0000000..3505961 --- /dev/null +++ b/adapter/matrix/store.py @@ -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}" diff --git a/core/handlers/chat.py b/core/handlers/chat.py index 8e32468..a7140b5 100644 --- a/core/handlers/chat.py +++ b/core/handlers/chat.py @@ -4,9 +4,19 @@ from __future__ import annotations 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: 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 ctx = await chat_mgr.get_or_create( 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: 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)) return [OutgoingMessage(chat_id=event.chat_id, text=f"Переименован в: {ctx.display_name}")] diff --git a/core/handlers/message.py b/core/handlers/message.py index e1475ef..2edb87e 100644 --- a/core/handlers/message.py +++ b/core/handlers/message.py @@ -4,9 +4,18 @@ from __future__ import annotations 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: 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 if event.attachments and event.attachments[0].type == "audio": diff --git a/tests/adapter/__init__.py b/tests/adapter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/adapter/matrix/__init__.py b/tests/adapter/matrix/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/tests/adapter/matrix/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/tests/adapter/matrix/test_converter.py b/tests/adapter/matrix/test_converter.py new file mode 100644 index 0000000..631b5fc --- /dev/null +++ b/tests/adapter/matrix/test_converter.py @@ -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 diff --git a/tests/adapter/matrix/test_dispatcher.py b/tests/adapter/matrix/test_dispatcher.py new file mode 100644 index 0000000..7b9b605 --- /dev/null +++ b/tests/adapter/matrix/test_dispatcher.py @@ -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() diff --git a/tests/adapter/matrix/test_reactions.py b/tests/adapter/matrix/test_reactions.py new file mode 100644 index 0000000..0c9fccc --- /dev/null +++ b/tests/adapter/matrix/test_reactions.py @@ -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 diff --git a/tests/adapter/matrix/test_store.py b/tests/adapter/matrix/test_store.py new file mode 100644 index 0000000..034bbd2 --- /dev/null +++ b/tests/adapter/matrix/test_store.py @@ -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"