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
|
||||
|
||||
|
||||
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}")]
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
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