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
2
adapter/__init__.py
Normal file
2
adapter/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
1
adapter/matrix/__init__.py
Normal file
1
adapter/matrix/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from __future__ import annotations
|
||||||
215
adapter/matrix/bot.py
Normal file
215
adapter/matrix/bot.py
Normal 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
143
adapter/matrix/converter.py
Normal 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),
|
||||||
|
)
|
||||||
41
adapter/matrix/handlers/__init__.py
Normal file
41
adapter/matrix/handlers/__init__.py
Normal 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)
|
||||||
34
adapter/matrix/handlers/auth.py
Normal file
34
adapter/matrix/handlers/auth.py
Normal 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})
|
||||||
50
adapter/matrix/handlers/chat.py
Normal file
50
adapter/matrix/handlers/chat.py
Normal 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="Чат архивирован.")]
|
||||||
19
adapter/matrix/handlers/confirm.py
Normal file
19
adapter/matrix/handlers/confirm.py
Normal 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}).")]
|
||||||
145
adapter/matrix/handlers/settings.py
Normal file
145
adapter/matrix/handlers/settings.py
Normal 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}.")]
|
||||||
68
adapter/matrix/reactions.py
Normal file
68
adapter/matrix/reactions.py
Normal 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,
|
||||||
|
},
|
||||||
|
)
|
||||||
23
adapter/matrix/room_router.py
Normal file
23
adapter/matrix/room_router.py
Normal 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
50
adapter/matrix/store.py
Normal 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}"
|
||||||
|
|
@ -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}")]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
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