diff --git a/.gitignore b/.gitignore index df96f790..570ff51f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ __pycache__/ build/ dist/ .coverage +openspec/ diff --git a/coworker/connectors/adapters.py b/coworker/connectors/adapters.py index 3d64d952..04d8a38c 100644 --- a/coworker/connectors/adapters.py +++ b/coworker/connectors/adapters.py @@ -459,6 +459,13 @@ def make_adapter( ) if profile.get("bot_token") and profile.get("app_token"): return SlackAdapter(profile["bot_token"], profile["app_token"]) + if platform == "matrix" and profile.get("access_token"): + from ..secrets import state_dir + from .matrix_adapter import MatrixAdapter + from .matrix_settings import MatrixSettings + + settings = MatrixSettings.from_profile(profile) + return MatrixAdapter(settings, store_path=state_dir() / "matrix" / "store") if platform == "github" and profile.get("mode") == "relay": if not (relay_url and token_provider): logger.warning( diff --git a/coworker/connectors/base.py b/coworker/connectors/base.py index e269c09e..2c0f60dd 100644 --- a/coworker/connectors/base.py +++ b/coworker/connectors/base.py @@ -8,11 +8,17 @@ from __future__ import annotations +import base64 +import re from abc import ABC, abstractmethod from dataclasses import asdict, dataclass, field from enum import Enum from typing import Any, Awaitable, Callable, Optional +_MATRIX_TARGET_RE = re.compile( + r"^matrix/([^/]+)(?:/thread/([^/]+))?$" +) + class MessageType(str, Enum): TEXT = "text" @@ -21,17 +27,57 @@ class MessageType(str, Enum): # -- target tokens ------------------------------------------------------------- +def encode_matrix_target( + room_id: str, thread_id: Optional[str] = None +) -> str: + """URL-safe base64 room (and optional thread) for Matrix reply handles.""" + enc = base64.urlsafe_b64encode(room_id.encode()).decode().rstrip("=") + if not thread_id: + return f"matrix/{enc}" + tenc = base64.urlsafe_b64encode(thread_id.encode()).decode().rstrip("=") + return f"matrix/{enc}/thread/{tenc}" + + +def decode_matrix_target(target: str) -> tuple[str, Optional[str]]: + """`matrix/[/thread/]` -> (room_id, thread_id).""" + m = _MATRIX_TARGET_RE.match((target or "").strip()) + if not m: + raise ValueError( + f"invalid matrix target {target!r} " + "(expected 'matrix/[/thread/]')" + ) + room_b64, thread_b64 = m.group(1), m.group(2) + + def _dec(part: str) -> str: + pad = "=" * (-len(part) % 4) + try: + return base64.urlsafe_b64decode(part + pad).decode() + except Exception as exc: + raise ValueError(f"invalid matrix target encoding in {target!r}") from exc + + room_id = _dec(room_b64) + thread_id = _dec(thread_b64) if thread_b64 else None + return room_id, thread_id + + def format_target(platform: str, chat_id: str, thread_id: Optional[str] = None) -> str: + if platform == "matrix": + return encode_matrix_target(chat_id, thread_id) base = f"{platform}:{chat_id}" return f"{base}:{thread_id}" if thread_id else base def parse_target(target: str) -> tuple[str, str, Optional[str]]: - """`'platform:chat_id[:thread]'` -> (platform, chat_id, thread_id).""" - parts = (target or "").split(":") + """`'platform:chat_id[:thread]'` or `matrix/[/thread/]` -> triple.""" + raw = (target or "").strip() + if raw.startswith("matrix/"): + room_id, thread_id = decode_matrix_target(raw) + return "matrix", room_id, thread_id + parts = raw.split(":") if len(parts) < 2 or not parts[0] or not parts[1]: raise ValueError( - f"invalid target {target!r} (expected 'platform:chat_id[:thread]')" + f"invalid target {target!r} (expected 'platform:chat_id[:thread]' " + "or 'matrix/[/thread/]')" ) thread = ":".join(parts[2:]) if len(parts) > 2 else None return parts[0], parts[1], (thread or None) @@ -95,6 +141,8 @@ class MessageEvent: # The bot itself was @-mentioned (UX-DECISIONS §31 mention router). Computed from the RAW # platform text at mapping time — mention tokens are rewritten for display afterwards. mentions_me: bool = False + # When set, delivered to the agent instead of `tagged_text()` — e.g. multimodal parts. + agent_content: Any = None def tagged_text(self) -> str: """How the message enters the super-agent thread: source + reply handle + text. @@ -119,10 +167,11 @@ class SendResult: @dataclass class InteractionEvent: - """A button click on an interactive prompt. + """A button click or emoji reaction on an interactive prompt. Stable actor/workspace ids are security inputs; display names are presentation only. `response_url` is Slack's short-lived reply capability for a private rejection notice. + Matrix reactions set `interaction_kind="reaction"` and `reaction_key` to the emoji. """ platform: str @@ -133,6 +182,8 @@ class InteractionEvent: user_name: Optional[str] = None team_id: Optional[str] = None response_url: Optional[str] = None + interaction_kind: str = "button" # "button" | "reaction" + reaction_key: Optional[str] = None InteractionHandler = Callable[[InteractionEvent], Awaitable[None]] diff --git a/coworker/connectors/catalog_copy.py b/coworker/connectors/catalog_copy.py index 2e5c801e..588f5d1b 100644 --- a/coworker/connectors/catalog_copy.py +++ b/coworker/connectors/catalog_copy.py @@ -19,6 +19,9 @@ "slack": "Bring your coworker into Slack: mention it in a channel or DM it, " "and replies land in-thread. Any number of workspaces can be connected, " "each with its own allow-list of who may talk to the agent.", + "matrix": "Chat with your coworker over Matrix (Element) on your own " + "homeserver. End-to-end encrypted rooms are supported; approve Inbox " + "requests with emoji reactions on mirrored prompts.", "email": "Read, search, and send mail on any IMAP account — Gmail, iCloud, " "Fastmail, or your own server — using an app password instead of your " "account password.", @@ -70,6 +73,13 @@ "Reads files shared in those channels.", "Reads member and channel names to resolve who's talking.", ], + "matrix": [ + "Reads messages in rooms the bot has joined (E2EE when enabled).", + "Sends encrypted messages and uploads files as the bot user.", + "Downloads media you share in those rooms (size-capped).", + "Inbox approvals resolve via emoji reactions on mirrored prompts.", + "Only senders on your allow-list are answered.", + ], "email": [ "Reads and searches mail over IMAP.", "Sends mail as your address, and saves attachments locally.", diff --git a/coworker/connectors/config.py b/coworker/connectors/config.py index 6553d15c..5094ce49 100644 --- a/coworker/connectors/config.py +++ b/coworker/connectors/config.py @@ -9,12 +9,12 @@ import os from dataclasses import dataclass, field -from typing import Optional +from typing import Any, Optional from ..secrets import SecretStore from .base import SessionSource -PLATFORMS = ("telegram", "slack", "github") +PLATFORMS = ("telegram", "slack", "matrix", "github") @dataclass @@ -62,6 +62,18 @@ def _csv(value: Optional[str]) -> set[str]: return {p.strip() for p in (value or "").split(",") if p.strip()} +def _profile_list(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, list): + return [str(v).strip() for v in value if str(v).strip()] + return [p.strip() for p in str(value).split(",") if p.strip()] + + +def _profile_set(value: Any) -> set[str]: + return set(_profile_list(value)) + + def load_settings( secrets: Optional[SecretStore] = None, ) -> dict[str, ConnectorSettings]: @@ -76,6 +88,8 @@ def load_settings( for platform in PLATFORMS: profile = secrets.get(f"{platform}:default") or {} token = profile.get("bot_token") + if platform == "matrix": + token = profile.get("access_token") allowed = set(profile.get("allowed_users") or []) allowed |= _csv(os.environ.get(f"{platform.upper()}_ALLOWED_USERS")) allow_all = bool(profile.get("allow_all")) or os.environ.get( diff --git a/coworker/connectors/descriptors.py b/coworker/connectors/descriptors.py index 9a6a34ab..df441549 100644 --- a/coworker/connectors/descriptors.py +++ b/coworker/connectors/descriptors.py @@ -118,6 +118,31 @@ def _validate_email(creds: dict) -> ValidationResult: return ValidationResult(ok, identity=identity or None, error=error or None) +def _validate_matrix(creds: dict) -> ValidationResult: + import httpx + + base = str(creds.get("homeserver_url") or "").rstrip("/") + token = creds.get("access_token", "") + if not base or not token: + return ValidationResult(False, error="homeserver_url and access_token required") + try: + resp = httpx.get( + f"{base}/_matrix/client/v3/account/whoami", + headers={"Authorization": f"Bearer {token}"}, + timeout=15, + ) + data = resp.json() + except Exception as exc: + return ValidationResult(False, error=str(exc)) + if resp.status_code >= 400: + detail = ( + (data.get("errcode") or data.get("error")) if isinstance(data, dict) else None + ) + return ValidationResult(False, error=str(detail or f"HTTP {resp.status_code}")) + user_id = data.get("user_id") if isinstance(data, dict) else None + return ValidationResult(True, identity=str(user_id or "matrix user")) + + def _validate_slack(creds: dict) -> ValidationResult: import httpx @@ -488,6 +513,61 @@ def _validate_outlook(creds: dict) -> ValidationResult: ], validate=_validate_slack, ), + ConnectorDescriptor( + name="matrix", + title="Matrix", + icon="⬡", + blurb="Two-way encrypted messaging on your Synapse or Element Server Suite homeserver.", + auth="token", + two_way=True, + channels=True, + brand_color="#0dbd8b", + logo="matrix", + fields=[ + Field( + "homeserver_url", + "Homeserver URL", + help="Your Synapse or ESS base URL, e.g. https://matrix.example.org", + placeholder="https://matrix.example.org", + ), + Field( + "access_token", + "Access token", + secret=True, + help="Bot user access token (from Element → Settings → Help & About).", + ), + Field( + "recovery_key", + "Recovery key", + secret=True, + help="Required for E2EE cross-signing bootstrap on encrypted rooms.", + ), + Field( + "user_id", + "User ID", + required=False, + help="Optional @user:server — filled automatically on connect.", + placeholder="@bot:example.org", + ), + _ALLOWED_FIELD, + Field( + "allowed_rooms", + "Allowed room IDs", + required=False, + help="Comma-separated room IDs. Empty = all joined rooms.", + placeholder="!abc:example.org", + ), + ], + instructions=[ + "Create a dedicated bot user on your Synapse or ESS homeserver.", + "Sign in with Element, open Settings → Help & About → Access Token.", + "Export your security recovery key (Settings → Security & Privacy) — required for E2EE.", + "Install libolm on this machine: brew install libolm (macOS) or apt install libolm-dev (Linux).", + "Invite the bot to encrypted rooms; it auto-accepts invites.", + "After connecting, message the bot once, then use Capture to grab your Matrix user ID.", + ], + validate=_validate_matrix, + ), ConnectorDescriptor( name="email", title="Email (IMAP)", diff --git a/coworker/connectors/matrix_adapter.py b/coworker/connectors/matrix_adapter.py new file mode 100644 index 00000000..6b4d2b95 --- /dev/null +++ b/coworker/connectors/matrix_adapter.py @@ -0,0 +1,598 @@ +"""Matrix inbound adapter — matrix-nio AsyncClient with required E2EE.""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path +from typing import Any, Optional + +from .base import ( + BasePlatformAdapter, + InteractionEvent, + MessageEvent, + MessageType, + SendResult, + SessionSource, +) +from .matrix_reactions import ( + PendingReaction, + PendingReactionStore, + reactions_for_buttons, +) +from .matrix_settings import MatrixSettings + +logger = logging.getLogger("coworker.connectors.matrix") + +# Sync bridge for stateless send_message tool (runs in worker threads). +_matrix_adapter: Optional["MatrixAdapter"] = None +_matrix_loop: Optional[asyncio.AbstractEventLoop] = None + + +def register_matrix_adapter( + adapter: Optional["MatrixAdapter"], + loop: Optional[asyncio.AbstractEventLoop] = None, +) -> None: + global _matrix_adapter, _matrix_loop + _matrix_adapter = adapter + _matrix_loop = loop + + +def send_matrix_sync(chat_id: str, text: str, thread_id: Optional[str] = None) -> SendResult: + """Blocking outbound send via the live adapter (E2EE-aware).""" + adapter = _matrix_adapter + loop = _matrix_loop + if adapter is None or loop is None: + return SendResult(False, error="matrix adapter not connected") + future = asyncio.run_coroutine_threadsafe( + adapter.send(chat_id, text, thread_id=thread_id), loop + ) + try: + return future.result(timeout=60) + except Exception as exc: + return SendResult(False, error=str(exc)) + + +def send_matrix_file_sync( + chat_id: str, + thread_id: Optional[str], + filename: str, + data: bytes, + title: Optional[str] = None, + comment: Optional[str] = None, +) -> SendResult: + adapter = _matrix_adapter + loop = _matrix_loop + if adapter is None or loop is None: + return SendResult(False, error="matrix adapter not connected") + future = asyncio.run_coroutine_threadsafe( + adapter.send_file_bytes( + chat_id, data, filename, thread_id=thread_id, title=title, comment=comment + ), + loop, + ) + try: + return future.result(timeout=120) + except Exception as exc: + return SendResult(False, error=str(exc)) + + +def _room_chat_type(room: Any, *, dm_rooms: set[str]) -> str: + if room.room_id in dm_rooms: + return "dm" + if _matrix_room_is_dm(room): + return "dm" + return "channel" + + +def _matrix_room_is_dm(room: Any) -> bool: + """True for 1:1 direct chats (summary, member count, or unnamed 2-person room).""" + try: + if room.member_count == 2: + return True + except (AttributeError, TypeError, ValueError): + pass + try: + if getattr(room, "is_group", False) and room.joined_count == 2: + return True + except (AttributeError, TypeError, ValueError): + pass + return False + + +def _mentions_bot(text: str, bot_user_id: Optional[str]) -> bool: + if not bot_user_id or not text: + return False + return bot_user_id in text or f"@{bot_user_id.split(':')[0][1:]}" in text + + +def matrix_event_to_event( + event: Any, + *, + room_id: str, + bot_user_id: Optional[str], + chat_type: str = "channel", + chat_name: Optional[str] = None, + thread_id: Optional[str] = None, +) -> Optional[MessageEvent]: + """Pure mapper: Matrix m.room.message (text) -> MessageEvent.""" + sender = getattr(event, "sender", None) or ( + event.get("sender") if isinstance(event, dict) else None + ) + if bot_user_id and sender == bot_user_id: + return None + body = getattr(event, "body", None) + if body is None and isinstance(event, dict): + body = (event.get("content") or {}).get("body") + if not body: + return None + event_id = getattr(event, "event_id", None) or ( + event.get("event_id") if isinstance(event, dict) else None + ) + source = SessionSource( + platform="matrix", + chat_id=room_id, + user_id=sender, + chat_type=chat_type, + chat_name=chat_name, + thread_id=thread_id, + ) + return MessageEvent( + text=str(body), + source=source, + message_id=event_id, + mentions_me=_mentions_bot(str(body), bot_user_id), + ) + + +class MatrixAdapter(BasePlatformAdapter): + platform = "matrix" + + def __init__( + self, + settings: MatrixSettings, + *, + store_path: Path, + reaction_store: Optional[PendingReactionStore] = None, + ) -> None: + super().__init__() + self.settings = settings + self.store_path = store_path + self.reaction_store = reaction_store or PendingReactionStore() + self._client = None + self._sync_task: Optional[asyncio.Task] = None + self._closing = False + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._joined_threads: set[tuple[str, str]] = set() + self._dm_rooms: set[str] = set() + self._dm_rooms_path: Optional[Path] = None + + def _note_thread(self, room_id: str, thread_id: Optional[str]) -> None: + if thread_id: + self._joined_threads.add((room_id, thread_id)) + + def _thread_active(self, room_id: str, thread_id: Optional[str]) -> bool: + if not thread_id: + return False + return (room_id, thread_id) in self._joined_threads + + def _load_dm_rooms(self) -> None: + path = self._dm_rooms_path + if path is None or not path.is_file(): + return + try: + import json + + data = json.loads(path.read_text(encoding="utf-8")) + if isinstance(data, list): + self._dm_rooms.update(str(r) for r in data) + except Exception: + logger.debug("matrix dm_rooms load failed", exc_info=True) + + def _remember_dm_room(self, room_id: str) -> None: + if room_id in self._dm_rooms: + return + self._dm_rooms.add(room_id) + path = self._dm_rooms_path + if path is None: + return + try: + import json + + path.write_text( + json.dumps(sorted(self._dm_rooms), indent=0) + "\n", + encoding="utf-8", + ) + except Exception: + logger.debug("matrix dm_rooms save failed", exc_info=True) + + def _is_dm(self, room: Any) -> bool: + room_id = room.room_id + if room_id in self._dm_rooms: + return True + if _matrix_room_is_dm(room): + self._remember_dm_room(room_id) + return True + return False + + def _should_dispatch(self, mapped: MessageEvent, room_id: str, *, is_dm: bool) -> bool: + if is_dm: + return True + if room_id in self.settings.free_response_rooms: + return True + if mapped.mentions_me: + return True + if self._thread_active(room_id, mapped.source.thread_id): + mapped.mentions_me = True + return True + if not self.settings.require_mention: + return True + return False + + async def connect(self) -> bool: + if self.settings.e2ee_mode == "required": + try: + import nio.crypto # noqa: F401 + except ImportError: + logger.warning( + "matrix E2EE requires matrix-nio[e2e] and libolm — " + "`pip install coworker[messaging]` and install libolm " + "(brew install libolm / apt install libolm-dev)" + ) + return False + + try: + from nio import AsyncClient, AsyncClientConfig, InviteMemberEvent, RoomMessageText + from nio.events import RoomMessage + from nio.events.room_events import ( + ReactionEvent, + RoomMessageAudio, + RoomMessageFile, + RoomMessageImage, + RoomMessageVideo, + ) + except ImportError: + logger.warning( + "matrix-nio not installed — `pip install coworker[messaging]`" + ) + return False + + if not self.settings.homeserver_url or not self.settings.access_token: + logger.warning("matrix: missing homeserver_url or access_token") + return False + + self.store_path.mkdir(parents=True, exist_ok=True) + self._dm_rooms_path = self.store_path / "dm_rooms.json" + self._load_dm_rooms() + user_id = self.settings.user_id or "" + # ponytail: without store_sync_tokens, restart replays full room timelines. + client_config = AsyncClientConfig(store_sync_tokens=True) + self._client = AsyncClient( + self.settings.homeserver_url, + user_id or "@bot:local", + store_path=str(self.store_path), + config=client_config, + ) + self._client.access_token = self.settings.access_token + if user_id: + self._client.user_id = user_id + + try: + resp = await self._client.whoami() + if hasattr(resp, "user_id") and resp.user_id: + self._client.user_id = resp.user_id + elif isinstance(resp, dict): + self._client.user_id = resp.get("user_id") or self._client.user_id + if hasattr(resp, "device_id") and resp.device_id: + self._client.device_id = resp.device_id + elif isinstance(resp, dict) and resp.get("device_id"): + self._client.device_id = resp["device_id"] + except Exception: + logger.exception("matrix whoami failed") + await self._client.close() + self._client = None + return False + + if hasattr(self._client, "load_store"): + try: + self._client.load_store() + except Exception: + logger.exception("matrix crypto store load failed") + await self._client.close() + self._client = None + return False + + if self.settings.e2ee_mode == "required": + try: + from .matrix_crypto_bootstrap import ( + MatrixCryptoBootstrapError, + prepare_matrix_e2ee, + ) + + await prepare_matrix_e2ee(self._client, self.settings) + except MatrixCryptoBootstrapError as exc: + logger.warning("matrix E2EE bootstrap failed: %s", exc) + await self._client.close() + self._client = None + return False + except Exception: + logger.exception("matrix E2EE bootstrap failed") + await self._client.close() + self._client = None + return False + + self._loop = asyncio.get_running_loop() + register_matrix_adapter(self, self._loop) + self._closing = False + + async def _dispatch(room, event, *, chat_type: str, thread_id: Optional[str]): + if not self._allowed_room(room.room_id, event.sender, chat_type=chat_type): + return + if self.settings.ignored_user(event.sender): + return + mapped = matrix_event_to_event( + event, + room_id=room.room_id, + bot_user_id=self._client.user_id, + chat_type=chat_type, + chat_name=getattr(room, "display_name", None), + thread_id=thread_id, + ) + if mapped is None: + return + is_dm = chat_type == "dm" + if not self._should_dispatch(mapped, room.room_id, is_dm=is_dm): + return + media = await self._media_agent_content(room.room_id, event) + if media is not None: + mapped.agent_content = media + mapped.message_type = MessageType.MEDIA + await self.handle_message(mapped) + + async def _on_room_message(room, event): + if self._is_dm(room): + chat_type = "dm" + else: + chat_type = _room_chat_type(room, dm_rooms=self._dm_rooms) + thread_id = None + relates = getattr(getattr(event, "content", None), "relates_to", None) + if relates is not None: + thread_id = getattr(relates, "event_id", None) + await _dispatch(room, event, chat_type=chat_type, thread_id=thread_id) + + async def _on_reaction(room, event): + if not isinstance(event, ReactionEvent): + return + await self._handle_reaction(room.room_id, event) + + async def _on_invite(room, event): + if isinstance(event, InviteMemberEvent): + try: + await self._client.join(room.room_id) + except Exception: + logger.debug("matrix auto-join failed for %s", room.room_id, exc_info=True) + + self._client.add_event_callback(_on_room_message, RoomMessageText) + for cls in (RoomMessageImage, RoomMessageFile, RoomMessageAudio, RoomMessageVideo): + self._client.add_event_callback(_on_room_message, cls) + self._client.add_event_callback(_on_reaction, ReactionEvent) + self._client.add_event_callback(_on_invite, InviteMemberEvent) + + self._sync_task = asyncio.create_task(self._sync_loop()) + logger.info("matrix adapter connected as %s", self._client.user_id) + return True + + async def _sync_loop(self) -> None: + # ponytail: with a saved sync token, one full_state sync refreshes room + # summaries/members without replaying timeline (since=token still applies). + first = True + while not self._closing and self._client is not None: + try: + full_state = first and bool( + getattr(self._client, "loaded_sync_token", None) + or getattr(self._client, "next_batch", None) + ) + first = False + await self._client.sync(timeout=30_000, full_state=full_state) + except asyncio.CancelledError: + break + except Exception: + logger.exception("matrix sync error — retrying") + await asyncio.sleep(2) + + def _allowed_room( + self, room_id: str, sender: Optional[str], *, chat_type: str = "channel" + ) -> bool: + allowed_rooms = self.settings.allowed_rooms + if not allowed_rooms: + return True + if chat_type == "dm" or room_id in self._dm_rooms: + return True + return room_id in allowed_rooms + + async def _media_agent_content(self, room_id: str, event: Any) -> Any | None: + """Download mxc media and build multimodal agent content, or None for text-only.""" + content = getattr(event, "content", None) or {} + if isinstance(content, dict): + url = content.get("url") or content.get("file", {}).get("url") + body = content.get("body") or content.get("filename") or "attachment" + else: + url = getattr(content, "url", None) + body = getattr(content, "body", None) or "attachment" + if not url or not str(url).startswith("mxc://"): + return None + if self._client is None: + return None + try: + resp = await self._client.download(url) + data = getattr(resp, "body", None) or getattr(resp, "content", None) + if data is None and hasattr(resp, "read"): + data = resp.read() + if not data: + return None + if len(data) > self.settings.max_media_bytes: + return None + except Exception: + logger.debug("matrix media download failed", exc_info=True) + return None + import base64 + from mimetypes import guess_type + + mime, _ = guess_type(str(body)) + mime = mime or "application/octet-stream" + inbound_dir = self.store_path / "inbound" + inbound_dir.mkdir(parents=True, exist_ok=True) + safe_name = str(body).replace("/", "_")[:120] or "attachment" + path = inbound_dir / safe_name + path.write_bytes(data) + tagged = matrix_event_to_event( + event, + room_id=room_id, + bot_user_id=self._client.user_id if self._client else None, + ) + frame = tagged.tagged_text() if tagged else f"[matrix media: {safe_name}]" + if mime.startswith("image/"): + b64 = base64.standard_b64encode(data).decode() + return [ + {"type": "text", "text": frame}, + {"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}}, + ] + return f"{frame}\n[Attachment saved: {path}]" + + async def _handle_reaction(self, room_id: str, event: Any) -> None: + relates = getattr(getattr(event, "content", None), "relates_to", None) + if relates is None: + return + prompt_id = getattr(relates, "event_id", None) + emoji = getattr(relates, "key", None) + if not prompt_id or not emoji: + return + resolved = self.reaction_store.resolve_emoji(room_id, prompt_id, emoji) + if resolved is None: + return + value, pending = resolved + if ( + self.settings.approval_require_sender + and pending.allowed_reactor + and event.sender != pending.allowed_reactor + ): + return + self.reaction_store.pop(room_id, prompt_id) + await self.handle_interaction( + InteractionEvent( + platform="matrix", + chat_id=room_id, + message_id=prompt_id, + value=value, + user_id=getattr(event, "sender", None), + interaction_kind="reaction", + reaction_key=emoji, + ) + ) + + async def disconnect(self) -> None: + self._closing = True + register_matrix_adapter(None, None) + if self._sync_task is not None: + self._sync_task.cancel() + self._sync_task = None + if self._client is not None: + try: + await self._client.close() + except Exception: + pass + self._client = None + + def _thread_content(self, thread_id: Optional[str]) -> dict: + if not thread_id: + return {} + return {"m.relates_to": {"rel_type": "m.thread", "event_id": thread_id}} + + async def send( + self, chat_id: str, text: str, *, thread_id: Optional[str] = None + ) -> SendResult: + if self._client is None: + return SendResult(False, error="matrix not connected") + content = {"msgtype": "m.text", "body": text[: self.settings.max_message_length]} + content.update(self._thread_content(thread_id)) + try: + resp = await self._client.room_send( + chat_id, "m.room.message", content, ignore_unverified_devices=True + ) + event_id = getattr(resp, "event_id", None) + self._note_thread(chat_id, thread_id) + if self.settings.auto_thread and not thread_id and event_id: + self._note_thread(chat_id, event_id) + return SendResult(True, message_id=event_id) + except Exception as exc: + return SendResult(False, error=str(exc)) + + async def send_interactive( + self, chat_id: str, text: str, buttons, *, thread_id: Optional[str] = None + ) -> SendResult: + hint = text + emoji_map = reactions_for_buttons(buttons) + if emoji_map: + keys = " ".join(emoji_map.keys()) + hint = f"{text}\n\nReact: {keys}" + result = await self.send(chat_id, hint, thread_id=thread_id) + if not result.ok or not result.message_id: + return result + self.reaction_store.register( + PendingReaction( + room_id=chat_id, + prompt_event_id=result.message_id, + emoji_map=emoji_map, + ) + ) + return result + + async def send_file_bytes( + self, + chat_id: str, + data: bytes, + filename: str, + *, + thread_id: Optional[str] = None, + title: Optional[str] = None, + comment: Optional[str] = None, + ) -> SendResult: + if self._client is None: + return SendResult(False, error="matrix not connected") + if len(data) > self.settings.max_media_bytes: + return SendResult(False, error="file exceeds max_media_bytes") + try: + from mimetypes import guess_type + + mime, _ = guess_type(filename) + mime = mime or "application/octet-stream" + upload = await self._client.upload(data, mime, filename=filename) + if hasattr(upload, "content_uri"): + mxc = upload.content_uri + else: + mxc = getattr(upload, "content_uri", None) or str(upload) + msgtype = "m.image" if mime.startswith("image/") else "m.file" + content: dict[str, Any] = { + "msgtype": msgtype, + "body": title or filename, + "url": mxc, + "filename": filename, + } + if comment: + content["body"] = f"{comment}\n{content['body']}" + content.update(self._thread_content(thread_id)) + resp = await self._client.room_send( + chat_id, "m.room.message", content, ignore_unverified_devices=True + ) + return SendResult(True, message_id=getattr(resp, "event_id", None)) + except Exception as exc: + return SendResult(False, error=str(exc)) + + async def update_message(self, chat_id: str, message_id: str, text: str) -> None: + if self._client is None or not message_id: + return + try: + await self._client.room_redact(chat_id, message_id, reason=text[:50]) + await self.send(chat_id, text) + except Exception: + logger.debug("matrix update_message failed", exc_info=True) diff --git a/coworker/connectors/matrix_crypto_bootstrap.py b/coworker/connectors/matrix_crypto_bootstrap.py new file mode 100644 index 00000000..96463bf4 --- /dev/null +++ b/coworker/connectors/matrix_crypto_bootstrap.py @@ -0,0 +1,387 @@ +"""Matrix E2EE bootstrap — recovery key (SSSS) + stale device-key detection. + +matrix-nio has no cross-signing / secret-storage support; this module implements the +subset needed for Element-style bot accounts on self-hosted Synapse: +- Parse Element recovery keys (base58) +- Decrypt cross-signing secrets from account data (m.secret_storage.v1.aes-hmac-sha2) +- Sign the current device with the self-signing key +- Detect local/server identity mismatch (stale OTM / deleted crypto store) +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import logging +from typing import Any, Optional + +import httpx +from Crypto.Cipher import AES +from Crypto.Hash import SHA256 +from Crypto.PublicKey import ECC +from Crypto.Protocol.KDF import HKDF +from Crypto.Signature import eddsa + +logger = logging.getLogger("coworker.connectors.matrix") + +_B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" +_SSSS_ZERO_SALT = b"\x00" * 32 +_CROSS_SIGNING_SELF = "m.cross_signing.self_signing" + + +class MatrixCryptoBootstrapError(Exception): + """E2EE bootstrap failed — connect must fail closed.""" + + +def _unpadded_b64(data: bytes) -> str: + return base64.b64encode(data).decode("ascii").rstrip("=") + + +def _b64_decode(data: str) -> bytes: + return base64.b64decode(data + "=" * (-len(data) % 4)) + + +def parse_recovery_key(raw: str) -> bytes: + """Element recovery / security key → 32-byte secret storage key. + + Accepts Element base58 recovery keys or a raw 32-byte hex string (64 hex chars). + """ + cleaned = "".join(raw.split()) + if not cleaned: + raise MatrixCryptoBootstrapError("recovery key is empty") + hex_candidate = cleaned.removeprefix("0x") + if len(hex_candidate) == 64 and all(c in "0123456789abcdefABCDEF" for c in hex_candidate): + try: + key = bytes.fromhex(hex_candidate) + except ValueError as exc: + raise MatrixCryptoBootstrapError("invalid recovery key hex") from exc + if len(key) != 32: + raise MatrixCryptoBootstrapError("recovery key hex must decode to 32 bytes") + return key + num = 0 + for ch in cleaned: + try: + num = num * 58 + _B58.index(ch) + except ValueError as exc: + raise MatrixCryptoBootstrapError("invalid recovery key encoding") from exc + # Preserve leading zero bytes. + full = num.to_bytes((num.bit_length() + 7) // 8 or 1, "big") + pad = len(cleaned) - len(cleaned.lstrip(_B58[0])) + decoded = b"\x00" * pad + full + if len(decoded) != 35: + raise MatrixCryptoBootstrapError( + f"recovery key decoded to {len(decoded)} bytes (expected 35)" + ) + if decoded[:2] != b"\x8b\x01": + raise MatrixCryptoBootstrapError("recovery key has invalid prefix") + key = decoded[2:34] + parity = decoded[34] + if (parity ^ _xor_bytes(decoded[:34])) & 0xFF: + raise MatrixCryptoBootstrapError("recovery key parity check failed") + return key + + +def _xor_bytes(data: bytes) -> int: + x = 0 + for b in data: + x ^= b + return x + + +def _hkdf_keys(storage_key: bytes, info: bytes) -> tuple[bytes, bytes]: + out = HKDF( + storage_key, + 64, + _SSSS_ZERO_SALT, + SHA256, + context=info, + ) + return out[:32], out[32:] + + +def _verify_storage_key(storage_key: bytes, key_desc: dict) -> None: + """Optional iv/mac self-check on m.secret_storage.key.* account data.""" + iv_b64 = key_desc.get("iv") + mac_b64 = key_desc.get("mac") + if not iv_b64 or not mac_b64: + return + aes_key, mac_key = _hkdf_keys(storage_key, b"") + iv = _b64_decode(iv_b64) + if len(iv) != 16: + raise MatrixCryptoBootstrapError("invalid secret storage key iv") + iv = bytearray(iv) + iv[8] &= 0x7F + cipher = AES.new(aes_key, AES.MODE_CTR, nonce=b"", initial_value=iv) + ct = cipher.encrypt(b"\x00" * 32) + expected = hmac.new(mac_key, ct, hashlib.sha256).digest() + if not hmac.compare_digest(_b64_decode(mac_b64), expected): + raise MatrixCryptoBootstrapError("recovery key does not match this account") + + +def _decrypt_secret( + storage_key: bytes, secret_name: str, blob: dict +) -> bytes: + aes_key, mac_key = _hkdf_keys(storage_key, secret_name.encode("utf-8")) + iv = _b64_decode(str(blob["iv"])) + if len(iv) != 16: + raise MatrixCryptoBootstrapError(f"invalid iv for secret {secret_name}") + iv = bytearray(iv) + iv[8] &= 0x7F + ct = _b64_decode(str(blob["ciphertext"])) + expected_mac = hmac.new(mac_key, ct, hashlib.sha256).digest() + if not hmac.compare_digest(_b64_decode(str(blob["mac"])), expected_mac): + raise MatrixCryptoBootstrapError(f"MAC mismatch for secret {secret_name}") + cipher = AES.new(aes_key, AES.MODE_CTR, nonce=b"", initial_value=bytes(iv)) + return cipher.decrypt(ct) + + +def _sign_json_ed25519(seed: bytes, payload: dict) -> str: + from nio.api import Api + + unsigned = {k: v for k, v in payload.items() if k not in ("signatures", "unsigned")} + message = Api.to_canonical_json(unsigned).encode("utf-8") + key = ECC.construct(curve="Ed25519", seed=seed) + return _unpadded_b64(eddsa.new(key, "rfc8032").sign(message)) + + +def _public_key_b64(seed: bytes) -> str: + raw = ECC.construct(curve="Ed25519", seed=seed).public_key().export_key(format="raw") + return _unpadded_b64(raw) + + +def _get_account_data( + base_url: str, token: str, user_id: str, event_type: str +) -> Optional[dict]: + from urllib.parse import quote + + url = ( + f"{base_url.rstrip('/')}/_matrix/client/v3/user/" + f"{quote(user_id, safe='')}/account_data/{quote(event_type, safe='')}" + ) + resp = httpx.get(url, headers={"Authorization": f"Bearer {token}"}, timeout=30) + if resp.status_code == 404: + return None + resp.raise_for_status() + return resp.json() + + +def _upload_signatures( + base_url: str, token: str, body: dict +) -> None: + url = f"{base_url.rstrip('/')}/_matrix/client/v3/keys/signatures/upload" + resp = httpx.post( + url, + headers={"Authorization": f"Bearer {token}"}, + json=body, + timeout=30, + ) + if resp.status_code >= 400: + detail = resp.text[:200] + raise MatrixCryptoBootstrapError( + f"signatures/upload failed ({resp.status_code}): {detail}" + ) + + +def _query_own_device_keys( + base_url: str, token: str, user_id: str, device_id: str +) -> Optional[dict]: + url = f"{base_url.rstrip('/')}/_matrix/client/v3/keys/query" + resp = httpx.post( + url, + headers={"Authorization": f"Bearer {token}"}, + json={"device_keys": {user_id: [device_id]}}, + timeout=30, + ) + if resp.status_code >= 400: + return None + data = resp.json() + dev = (data.get("device_keys") or {}).get(user_id, {}).get(device_id) + return dev if isinstance(dev, dict) else None + + +def detect_stale_device_keys( + *, + base_url: str, + token: str, + user_id: str, + device_id: str, + local_curve25519: str, + local_ed25519: str, +) -> Optional[str]: + """Return an actionable error if the homeserver has different identity keys.""" + remote = _query_own_device_keys(base_url, token, user_id, device_id) + if not remote: + return None + keys = remote.get("keys") or {} + remote_curve = keys.get(f"curve25519:{device_id}") + remote_ed = keys.get(f"ed25519:{device_id}") + if not remote_curve and not remote_ed: + return None + if remote_curve and remote_curve != local_curve25519: + return ( + f"device {device_id} has stale keys on the server (identity key mismatch). " + "Delete the local crypto store or generate a new access token (fresh device id)." + ) + if remote_ed and remote_ed != local_ed25519: + return ( + f"device {device_id} has stale signing keys on the server. " + "Generate a new access token or delete the device via Synapse admin API." + ) + return None + + +def bootstrap_cross_signing( + *, + base_url: str, + token: str, + user_id: str, + device_id: str, + recovery_key: str, + device_keys: dict, +) -> None: + """Import self-signing key from SSSS and cross-sign the current device.""" + storage_key = parse_recovery_key(recovery_key) + + default = _get_account_data(base_url, token, user_id, "m.secret_storage.default_key") + if not default or not default.get("key"): + raise MatrixCryptoBootstrapError( + "no default secret storage key on account — set up cross-signing in Element first" + ) + key_id = str(default["key"]) + key_desc = _get_account_data( + base_url, token, user_id, f"m.secret_storage.key.{key_id}" + ) + if not key_desc: + raise MatrixCryptoBootstrapError(f"secret storage key {key_id!r} not found") + _verify_storage_key(storage_key, key_desc) + + secret_event = _get_account_data( + base_url, token, user_id, "m.cross_signing.self_signing" + ) + if not secret_event or "encrypted" not in secret_event: + raise MatrixCryptoBootstrapError( + "m.cross_signing.self_signing not in account data — enable secure backup in Element" + ) + enc = (secret_event.get("encrypted") or {}).get(key_id) + if not enc: + raise MatrixCryptoBootstrapError( + "self_signing secret not encrypted with the default storage key" + ) + plain = _decrypt_secret(storage_key, _CROSS_SIGNING_SELF, enc) + try: + parsed = json.loads(plain.decode("utf-8")) + except Exception as exc: + raise MatrixCryptoBootstrapError("self_signing secret is not valid JSON") from exc + seed_b64 = parsed.get("private_key") or parsed.get("key") + if not seed_b64: + raise MatrixCryptoBootstrapError("self_signing secret missing private_key") + seed = _b64_decode(str(seed_b64)) + if len(seed) != 32: + raise MatrixCryptoBootstrapError("self_signing private key must be 32 bytes") + + unsigned = { + k: v for k, v in device_keys.items() if k not in ("signatures", "unsigned") + } + sig = _sign_json_ed25519(seed, unsigned) + pub = _public_key_b64(seed) + signed = dict(device_keys) + signatures = dict(signed.get("signatures") or {}) + user_sigs = dict(signatures.get(user_id) or {}) + user_sigs[f"ed25519:{pub}"] = sig + signatures[user_id] = user_sigs + signed["signatures"] = signatures + + _upload_signatures( + base_url, + token, + {user_id: {device_id: signed}}, + ) + logger.info("matrix cross-signing: signed device %s", device_id) + + +async def prepare_matrix_e2ee(client: Any, settings: Any) -> None: + """Run stale-key check, keys upload, and optional cross-signing bootstrap.""" + if settings.e2ee_mode != "required": + return + if client.olm is None: + raise MatrixCryptoBootstrapError("encryption store not loaded") + + user_id = client.user_id + device_id = client.device_id or getattr(client.olm, "device_id", None) + if not user_id or not device_id: + raise MatrixCryptoBootstrapError("whoami did not return user_id/device_id") + + local_curve = client.olm.account.identity_keys["curve25519"] + local_ed = client.olm.account.identity_keys["ed25519"] + stale = detect_stale_device_keys( + base_url=settings.homeserver_url, + token=settings.access_token, + user_id=user_id, + device_id=device_id, + local_curve25519=local_curve, + local_ed25519=local_ed, + ) + if stale: + raise MatrixCryptoBootstrapError(stale) + + from nio.responses import KeysUploadError + + if client.should_upload_keys: + resp = await client.keys_upload() + if isinstance(resp, KeysUploadError): + msg = getattr(resp, "message", None) or str(resp) + if "identity" in msg.lower() or "one.time" in msg.lower(): + raise MatrixCryptoBootstrapError( + f"keys/upload failed (stale device keys?): {msg}. " + "Generate a new access token or delete the device on Synapse." + ) + raise MatrixCryptoBootstrapError(f"keys/upload failed: {msg}") + + import asyncio + + default = await asyncio.to_thread( + _get_account_data, + settings.homeserver_url, + settings.access_token, + user_id, + "m.secret_storage.default_key", + ) + if not default or not default.get("key"): + logger.info( + "matrix: account has no secret storage — skipping cross-signing bootstrap" + ) + return + + if not settings.recovery_key: + raise MatrixCryptoBootstrapError( + "recovery_key is required — this account has cross-signing enabled. " + "Export it from Element (Settings → Security & Privacy → Recovery key)." + ) + + device_keys = _local_device_keys(client.olm, user_id, device_id) + await asyncio.to_thread( + bootstrap_cross_signing, + base_url=settings.homeserver_url, + token=settings.access_token, + user_id=user_id, + device_id=device_id, + recovery_key=settings.recovery_key, + device_keys=device_keys, + ) + + +def _local_device_keys(olm: Any, user_id: str, device_id: str) -> dict: + base = { + "algorithms": olm._algorithms, + "device_id": device_id, + "user_id": user_id, + "keys": { + f"curve25519:{device_id}": olm.account.identity_keys["curve25519"], + f"ed25519:{device_id}": olm.account.identity_keys["ed25519"], + }, + } + sig = olm.sign_json(base) + base["signatures"] = {user_id: {f"ed25519:{device_id}": sig}} + return base diff --git a/coworker/connectors/matrix_reactions.py b/coworker/connectors/matrix_reactions.py new file mode 100644 index 00000000..6e63f638 --- /dev/null +++ b/coworker/connectors/matrix_reactions.py @@ -0,0 +1,95 @@ +"""Matrix emoji reactions for Inbox prompts — pending registry + emoji maps.""" + +from __future__ import annotations + +import threading +from dataclasses import dataclass +from typing import Optional + +from ..interactions import Button, encode +from ..inbox import KIND_APPROVAL, KIND_QUESTION + +APPROVAL_EMOJI: dict[str, str] = { + "✅": "allow", + "♾️": "always", + "❌": "deny", +} + +NUMBER_EMOJI = ("1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟") + + +@dataclass +class PendingReaction: + room_id: str + prompt_event_id: str + emoji_map: dict[str, str] # emoji -> encoded value + allowed_reactor: Optional[str] = None + + +class PendingReactionStore: + def __init__(self) -> None: + self._lock = threading.Lock() + self._pending: dict[tuple[str, str], PendingReaction] = {} + + def register(self, pending: PendingReaction) -> None: + key = (pending.room_id, pending.prompt_event_id) + with self._lock: + self._pending[key] = pending + + def lookup( + self, room_id: str, prompt_event_id: str + ) -> Optional[PendingReaction]: + with self._lock: + return self._pending.get((room_id, prompt_event_id)) + + def pop(self, room_id: str, prompt_event_id: str) -> Optional[PendingReaction]: + key = (room_id, prompt_event_id) + with self._lock: + return self._pending.pop(key, None) + + def resolve_emoji( + self, room_id: str, relates_to_event_id: str, emoji: str + ) -> Optional[tuple[str, PendingReaction]]: + pending = self.lookup(room_id, relates_to_event_id) + if pending is None: + return None + value = pending.emoji_map.get(emoji) + if value is None: + return None + return value, pending + + +def reactions_for(item) -> dict[str, str]: + """Build emoji map for an Inbox item mirrored to Matrix.""" + emoji_map: dict[str, str] = {} + if item.kind == KIND_APPROVAL: + for emoji, resolution in APPROVAL_EMOJI.items(): + emoji_map[emoji] = encode(item.id, resolution) + elif item.kind == KIND_QUESTION and getattr(item, "options", None): + for i, opt in enumerate(item.options): + if i >= len(NUMBER_EMOJI): + break + emoji_map[NUMBER_EMOJI[i]] = encode(item.id, opt) + return emoji_map + + +def reactions_for_buttons(buttons: list[Button]) -> dict[str, str]: + """Map emoji keys to button values for interactive Matrix prompts.""" + out: dict[str, str] = {} + for i, btn in enumerate(buttons): + if btn.label == "Approve": + out["✅"] = btn.value + # Hermes-style approve-always (♾️) — same item id, resolution "always". + try: + import json + + d = json.loads(btn.value) + if isinstance(d, dict) and d.get("id"): + out["♾️"] = encode(str(d["id"]), "always") + except Exception: + pass + elif btn.label == "Deny": + out["❌"] = btn.value + elif i < len(NUMBER_EMOJI): + out[NUMBER_EMOJI[i]] = btn.value + return out diff --git a/coworker/connectors/matrix_settings.py b/coworker/connectors/matrix_settings.py new file mode 100644 index 00000000..43e60f4e --- /dev/null +++ b/coworker/connectors/matrix_settings.py @@ -0,0 +1,67 @@ +"""Matrix connector settings from the `matrix:default` profile.""" + +from __future__ import annotations + +import os +import re +from dataclasses import dataclass, field +from typing import Optional + +from .config import _csv, _profile_list, _profile_set + + +@dataclass +class MatrixSettings: + homeserver_url: str + access_token: str + user_id: Optional[str] = None + recovery_key: Optional[str] = None + allowed_users: set[str] = field(default_factory=set) + allowed_rooms: set[str] = field(default_factory=set) + free_response_rooms: set[str] = field(default_factory=set) + ignore_user_patterns: list[re.Pattern[str]] = field(default_factory=list) + require_mention: bool = True + auto_thread: bool = True + e2ee_mode: str = "required" + max_message_length: int = 4000 + max_media_bytes: int = 104_857_600 + approval_require_sender: bool = True + + @classmethod + def from_profile(cls, profile: dict) -> "MatrixSettings": + patterns = [] + raw_patterns = _profile_list(profile.get("ignore_user_patterns")) + if not raw_patterns: + raw_patterns = ["^@telegram_", "^@slack_", "^@whatsapp_"] + for raw in raw_patterns: + try: + patterns.append(re.compile(raw)) + except re.error: + continue + allowed_rooms = _profile_set(profile.get("allowed_rooms")) | _csv( + os.environ.get("MATRIX_ALLOWED_ROOMS") + ) + free_response_rooms = _profile_set(profile.get("free_response_rooms")) | _csv( + os.environ.get("MATRIX_FREE_RESPONSE_ROOMS") + ) + return cls( + homeserver_url=str(profile.get("homeserver_url") or "").rstrip("/"), + access_token=str(profile.get("access_token") or ""), + user_id=profile.get("user_id"), + recovery_key=profile.get("recovery_key"), + allowed_users=_profile_set(profile.get("allowed_users")), + allowed_rooms=allowed_rooms, + free_response_rooms=free_response_rooms, + ignore_user_patterns=patterns, + require_mention=bool(profile.get("require_mention", True)), + auto_thread=bool(profile.get("auto_thread", True)), + e2ee_mode=str(profile.get("e2ee_mode") or "required"), + max_message_length=int(profile.get("max_message_length") or 4000), + max_media_bytes=int(profile.get("max_media_bytes") or 104_857_600), + approval_require_sender=bool(profile.get("approval_require_sender", True)), + ) + + def ignored_user(self, user_id: Optional[str]) -> bool: + if not user_id: + return False + return any(p.search(user_id) for p in self.ignore_user_patterns) diff --git a/coworker/connectors/senders.py b/coworker/connectors/senders.py index 9ca9ff8e..2c812116 100644 --- a/coworker/connectors/senders.py +++ b/coworker/connectors/senders.py @@ -144,6 +144,17 @@ def _send_slack_interactive( } +def _send_matrix( + token: str, chat_id: str, text: str, thread_id: Optional[str] = None +) -> SendResult: + from .matrix_adapter import send_matrix_sync + + return send_matrix_sync(chat_id, text, thread_id) + + +DEFAULT_SENDERS["matrix"] = _send_matrix + + # -- file upload (§34 / UX-016) -------------------------------------------------------- # A FileSender is (token, chat_id, thread_id, filename, data, title, comment) -> SendResult. FileSender = Callable[ @@ -214,3 +225,22 @@ def _send_slack_file( DEFAULT_FILE_SENDERS: dict[str, FileSender] = { "slack": _send_slack_file, } + + +def _send_matrix_file( + token: str, + chat_id: str, + thread_id: Optional[str], + filename: str, + data: bytes, + title: Optional[str] = None, + comment: Optional[str] = None, +) -> SendResult: + from .matrix_adapter import send_matrix_file_sync + + return send_matrix_file_sync( + chat_id, thread_id, filename, data, title=title, comment=comment + ) + + +DEFAULT_FILE_SENDERS["matrix"] = _send_matrix_file diff --git a/coworker/connectors/tools.py b/coworker/connectors/tools.py index 8b2f4ce4..8c9b2744 100644 --- a/coworker/connectors/tools.py +++ b/coworker/connectors/tools.py @@ -22,18 +22,21 @@ "function": { "name": "send_message", "description": ( - "Send a message to a connected chat (Slack or Telegram). `target` is the " - "reply handle from an inbound message (e.g. 'telegram:12345' or 'slack:C0123', " - "optionally with a ':' suffix) — or, for Slack, just the channel NAME " - "('#general' or 'general'; resolved against the connected workspaces). Use this to " - "actually reach a person — plain assistant text is not delivered anywhere." + "Send a message to a connected chat (Slack, Telegram, or Matrix). `target` is the " + "reply handle from an inbound message (e.g. 'telegram:12345', 'slack:C0123', " + "or 'matrix/[/thread/]') — or, for Slack, just the " + "channel NAME ('#general' or 'general'; resolved against the connected workspaces). " + "Use this to actually reach a person — plain assistant text is not delivered anywhere." ), "parameters": { "type": "object", "properties": { "target": { "type": "string", - "description": "Destination handle 'platform:chat_id[:thread]', e.g. 'telegram:12345'.", + "description": ( + "Destination handle: 'platform:chat_id[:thread]' for Slack/Telegram, " + "or 'matrix/[/thread/]' for Matrix." + ), }, "text": {"type": "string", "description": "The message text to send."}, }, @@ -128,6 +131,8 @@ def _resolve_token(secrets: SecretStore, platform: str, chat_id: str) -> Optiona per_team = secrets.get(f"slack:team:{team}") or {} return per_team.get("bot_token") creds = secrets.get(f"{platform}:default") or {} + if platform == "matrix": + return creds.get("access_token") return creds.get("bot_token") @@ -184,7 +189,7 @@ def send_message(target: str, text: str) -> dict[str, Any]: "function": { "name": "send_file", "description": ( - "Upload a file from the session's workspace into a connected chat (Slack). " + "Upload a file from the session's workspace into a connected chat (Slack or Matrix). " "`target` is the same handle send_message uses. Slack shows its own previews " "for pdf/csv/images — send the actual file, not a screenshot of it. For .html " "artifacts (which Slack can't preview) set as_screenshot=true to send a " @@ -196,7 +201,10 @@ def send_message(target: str, text: str) -> dict[str, Any]: "properties": { "target": { "type": "string", - "description": "Destination handle 'platform:chat_id[:thread]', e.g. 'slack:C0123:171234.5678'.", + "description": ( + "Destination handle (same as send_message): " + "'platform:chat_id[:thread]' or matrix encoding." + ), }, "path": { "type": "string", diff --git a/coworker/server/manager.py b/coworker/server/manager.py index fb237ee8..54abd302 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -2721,7 +2721,12 @@ async def mirror_inbox_item(self, item) -> None: # available in-app, but never mirror it to an ownerless channel. if not self.slack_approval_owner_ids(team_id): return - target = f"{binding.channel}:{binding.target}" + if binding.channel == "matrix": + target = binding.target + if not target.startswith("matrix/"): + target = f"matrix/{binding.target}" + else: + target = f"{binding.channel}:{binding.target}" body = "\n".join(p for p in (item.title, item.body) if p).strip() buttons = buttons_for(item) try: @@ -2956,8 +2961,13 @@ async def _dispatch_inbound(self, event) -> None: return # channel with no subscribers — nobody is listening # DM (or any non-channel): route to the designated session, else park it for visibility. dm = self.dm_session() + agent_msg = ( + event.agent_content + if getattr(event, "agent_content", None) is not None + else event.tagged_text() + ) if dm and self._inbound_connector_allowed(dm, src.platform): - await self.deliver_to_session(dm, event.tagged_text(), source=ms.to_dict()) + await self.deliver_to_session(dm, agent_msg, source=ms.to_dict()) elif dm: # Designated, but this session has muted the connector → park rather than deliver. self.unrouted.record( diff --git a/pyproject.toml b/pyproject.toml index 687dad70..cfe2ed66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ dev = ["pytest>=8", "pytest-asyncio", "httpx"] # Inbound messaging listeners (outbound send_message needs only httpx, already a core dep). # aiohttp is slack-bolt's Socket Mode transport at runtime (and the FakeSlack test harness # drives the real handler) — declare it so CI installs it, not just transitively. -messaging = ["python-telegram-bot>=21", "slack-bolt>=1.18", "aiohttp>=3.9"] +messaging = ["python-telegram-bot>=21", "slack-bolt>=1.18", "aiohttp>=3.9", "matrix-nio[e2e]>=0.25"] # Interactive Cowork browser automation. browser = ["playwright>=1.44"] # AWS Bedrock provider (lazy-imported; desktop builds bundle it, pip users opt in). diff --git a/surfaces/gui/src/connectors/registry.tsx b/surfaces/gui/src/connectors/registry.tsx index 161e37c7..51b878be 100644 --- a/surfaces/gui/src/connectors/registry.tsx +++ b/surfaces/gui/src/connectors/registry.tsx @@ -181,6 +181,14 @@ const HunterLogo = strokeLogo( , ); +// Matrix / Element — no stable simple-icons export in our pin; hex grid stroke glyph. +const MatrixLogo = strokeLogo( + <> + + + , +); + const PlugLogo = strokeLogo( <> @@ -208,6 +216,7 @@ export const CONNECTORS: Record = { hubspot: brand(siHubspot), jira: brand(siJira), linear: brand(siLinear), + matrix: { label: "Matrix", logo: MatrixLogo }, mixpanel: brand(siMixpanel), notion: brand(siNotion), pagerduty: brand(siPagerduty), diff --git a/tests/test_matrix_connector.py b/tests/test_matrix_connector.py new file mode 100644 index 00000000..d2087244 --- /dev/null +++ b/tests/test_matrix_connector.py @@ -0,0 +1,229 @@ +"""Matrix connector unit tests — mapper, settings, reactions (no network).""" + +from __future__ import annotations + +from types import SimpleNamespace + +from coworker.connectors.matrix_adapter import matrix_event_to_event +from coworker.connectors.matrix_reactions import ( + APPROVAL_EMOJI, + PendingReactionStore, + reactions_for, + reactions_for_buttons, +) +from coworker.connectors.matrix_settings import MatrixSettings +from coworker.interactions import Button, decode, encode +from coworker.inbox import InboxStore + + +def test_matrix_settings_defaults(): + s = MatrixSettings.from_profile( + { + "homeserver_url": "https://matrix.example.org", + "access_token": "tok", + } + ) + assert s.require_mention is True + assert s.e2ee_mode == "required" + assert s.approval_require_sender is True + assert s.max_media_bytes == 104_857_600 + + +def test_reactions_for_buttons_includes_always(): + item_id = "abc123" + buttons = [ + Button("Approve", encode(item_id, "allow")), + Button("Deny", encode(item_id, "deny")), + ] + emap = reactions_for_buttons(buttons) + assert "✅" in emap and "❌" in emap and "♾️" in emap + assert decode(emap["♾️"]) == (item_id, "always") + + +def test_matrix_room_is_dm(): + from coworker.connectors.matrix_adapter import _matrix_room_is_dm + + dm = SimpleNamespace(member_count=2, is_group=True, joined_count=2) + assert _matrix_room_is_dm(dm) is True + channel = SimpleNamespace(member_count=10, is_group=False, joined_count=10) + assert _matrix_room_is_dm(channel) is False + unnamed = SimpleNamespace(member_count=0, is_group=True, joined_count=2) + assert _matrix_room_is_dm(unnamed) is True + + +def test_matrix_adapter_should_dispatch_mention(): + from coworker.connectors.matrix_adapter import MatrixAdapter + from coworker.connectors.base import MessageEvent, SessionSource + + adapter = MatrixAdapter( + MatrixSettings.from_profile( + {"homeserver_url": "https://h", "access_token": "t"} + ), + store_path=__import__("pathlib").Path("/tmp/matrix-test"), + ) + src = SessionSource(platform="matrix", chat_id="!r:ex", chat_type="channel") + ev = MessageEvent(text="hi", source=src, mentions_me=False) + assert adapter._should_dispatch(ev, "!r:ex", is_dm=False) is False + ev.mentions_me = True + assert adapter._should_dispatch(ev, "!r:ex", is_dm=False) is True + event = SimpleNamespace( + sender="@alice:example.org", + body="hello @bot:example.org", + event_id="$e1", + ) + mapped = matrix_event_to_event( + event, + room_id="!r:example.org", + bot_user_id="@bot:example.org", + chat_type="channel", + ) + assert mapped is not None + assert mapped.mentions_me is True + assert mapped.source.platform == "matrix" + assert mapped.source.target.startswith("matrix/") + + +def test_matrix_event_mapper_skips_bot(): + event = SimpleNamespace( + sender="@bot:example.org", + body="echo", + event_id="$e2", + ) + assert ( + matrix_event_to_event( + event, room_id="!r:example.org", bot_user_id="@bot:example.org" + ) + is None + ) + + +def test_reaction_store_resolve(): + store = PendingReactionStore() + from coworker.connectors.matrix_reactions import PendingReaction + + val = encode("item1", "allow") + store.register( + PendingReaction( + room_id="!r:ex", + prompt_event_id="$prompt", + emoji_map={"✅": val}, + allowed_reactor="@alice:ex", + ) + ) + got = store.resolve_emoji("!r:ex", "$prompt", "✅") + assert got is not None + assert got[0] == val + assert store.resolve_emoji("!r:ex", "$prompt", "❌") is None + + +def test_reactions_for_approval(tmp_path): + st = InboxStore(tmp_path / "inbox.json") + item = st.add_approval("s1", "Run tool?") + emojis = reactions_for(item) + assert "✅" in emojis + assert decode(emojis["✅"]) == (item.id, "allow") + assert decode(emojis["♾️"]) == (item.id, "always") + assert APPROVAL_EMOJI["❌"] == "deny" + + +def test_reactions_for_buttons(): + btns = [ + Button("Approve", encode("x", "allow")), + Button("Deny", encode("x", "deny")), + ] + em = reactions_for_buttons(btns) + assert em["✅"] == btns[0].value + assert em["❌"] == btns[1].value + + +def test_matrix_adapter_enables_sync_token_store(monkeypatch): + """Restart must not replay history — nio needs store_sync_tokens=True.""" + from pathlib import Path + + from coworker.connectors.matrix_adapter import MatrixAdapter + + captured: dict = {} + + class FakeClient: + user_id = "@bot:ex" + device_id = "DEV" + access_token = None + + async def whoami(self): + return SimpleNamespace(user_id=self.user_id, device_id=self.device_id) + + def load_store(self): + return None + + def add_event_callback(self, *_args, **_kwargs): + return None + + async def close(self): + return None + + def fake_async_client(*_args, **kwargs): + captured["config"] = kwargs.get("config") + return FakeClient() + + monkeypatch.setitem( + __import__("sys").modules, + "nio", + SimpleNamespace( + AsyncClient=fake_async_client, + AsyncClientConfig=__import__("nio").AsyncClientConfig, + InviteMemberEvent=object, + RoomMessageText=object, + ), + ) + monkeypatch.setitem( + __import__("sys").modules, + "nio.events", + SimpleNamespace(RoomMessage=object), + ) + monkeypatch.setitem( + __import__("sys").modules, + "nio.events.room_events", + SimpleNamespace( + ReactionEvent=object, + RoomMessageAudio=object, + RoomMessageFile=object, + RoomMessageImage=object, + RoomMessageVideo=object, + ), + ) + monkeypatch.setitem( + __import__("sys").modules, + "nio.crypto", + SimpleNamespace(), + ) + from coworker.connectors.matrix_crypto_bootstrap import prepare_matrix_e2ee + + async def noop_prepare(*_args, **_kwargs): + return None + + monkeypatch.setattr( + "coworker.connectors.matrix_adapter.prepare_matrix_e2ee", + noop_prepare, + raising=False, + ) + monkeypatch.setattr( + "coworker.connectors.matrix_crypto_bootstrap.prepare_matrix_e2ee", + noop_prepare, + ) + + adapter = MatrixAdapter( + MatrixSettings.from_profile( + { + "homeserver_url": "https://h", + "access_token": "t", + "e2ee_mode": "off", + } + ), + store_path=Path("/tmp/matrix-sync-token-test"), + ) + + import asyncio + + assert asyncio.run(adapter.connect()) is True + assert captured["config"] is not None + assert captured["config"].store_sync_tokens is True diff --git a/tests/test_matrix_crypto_bootstrap.py b/tests/test_matrix_crypto_bootstrap.py new file mode 100644 index 00000000..84a35122 --- /dev/null +++ b/tests/test_matrix_crypto_bootstrap.py @@ -0,0 +1,361 @@ +"""Unit tests for Matrix E2EE bootstrap (recovery key, stale keys, SSSS decrypt).""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest +from Crypto.Cipher import AES +from Crypto.Hash import SHA256 +from Crypto.Protocol.KDF import HKDF + +from coworker.connectors.matrix_crypto_bootstrap import ( + MatrixCryptoBootstrapError, + _B58, + _SSSS_ZERO_SALT, + _decrypt_secret, + _hkdf_keys, + _unpadded_b64, + _verify_storage_key, + _xor_bytes, + bootstrap_cross_signing, + detect_stale_device_keys, + parse_recovery_key, + prepare_matrix_e2ee, +) + + +def _encode_recovery_key(key: bytes) -> str: + assert len(key) == 32 + payload = b"\x8b\x01" + key + parity = _xor_bytes(payload) & 0xFF + data = payload + bytes([parity]) + pad = 0 + for b in data: + if b == 0: + pad += 1 + else: + break + num = int.from_bytes(data, "big") + encoded = "" + while num > 0: + num, rem = divmod(num, 58) + encoded = _B58[rem] + encoded + return _B58[0] * pad + encoded + + +def _encrypt_secret(storage_key: bytes, secret_name: str, plaintext: bytes) -> dict: + aes_key, mac_key = _hkdf_keys(storage_key, secret_name.encode("utf-8")) + iv = os.urandom(16) + iv_arr = bytearray(iv) + iv_arr[8] &= 0x7F + cipher = AES.new(aes_key, AES.MODE_CTR, nonce=b"", initial_value=bytes(iv_arr)) + ct = cipher.encrypt(plaintext) + mac = hmac.new(mac_key, ct, hashlib.sha256).digest() + return { + "iv": _unpadded_b64(iv), + "ciphertext": _unpadded_b64(ct), + "mac": _unpadded_b64(mac), + } + + +def _storage_key_self_check(storage_key: bytes) -> dict: + aes_key, mac_key = _hkdf_keys(storage_key, b"") + iv = os.urandom(16) + iv_arr = bytearray(iv) + iv_arr[8] &= 0x7F + cipher = AES.new(aes_key, AES.MODE_CTR, nonce=b"", initial_value=bytes(iv_arr)) + ct = cipher.encrypt(b"\x00" * 32) + mac = hmac.new(mac_key, ct, hashlib.sha256).digest() + return {"iv": _unpadded_b64(iv), "mac": _unpadded_b64(mac)} + + +def test_parse_recovery_key_hex(): + key = os.urandom(32) + assert parse_recovery_key(key.hex()) == key + assert parse_recovery_key("0x" + key.hex()) == key + + +def test_parse_recovery_key_roundtrip(): + key = os.urandom(32) + encoded = _encode_recovery_key(key) + assert parse_recovery_key(encoded) == key + assert parse_recovery_key(" " + encoded + " ") == key + + +def test_parse_recovery_key_invalid(): + with pytest.raises(MatrixCryptoBootstrapError, match="empty"): + parse_recovery_key("") + with pytest.raises(MatrixCryptoBootstrapError, match="invalid recovery key encoding"): + parse_recovery_key("!!!") + + +def test_decrypt_secret_roundtrip(): + storage_key = os.urandom(32) + name = "m.cross_signing.self_signing" + plain = b'{"private_key":"abc"}' + blob = _encrypt_secret(storage_key, name, plain) + assert _decrypt_secret(storage_key, name, blob) == plain + + +def test_verify_storage_key(): + storage_key = os.urandom(32) + desc = _storage_key_self_check(storage_key) + _verify_storage_key(storage_key, desc) + with pytest.raises(MatrixCryptoBootstrapError, match="does not match"): + _verify_storage_key(os.urandom(32), desc) + + +def test_detect_stale_device_keys_mismatch(): + device_id = "DEV123" + user_id = "@bot:example.com" + remote = { + "keys": { + f"curve25519:{device_id}": "remote_curve", + f"ed25519:{device_id}": "remote_ed", + } + } + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path.endswith("/keys/query") + return httpx.Response(200, json={"device_keys": {user_id: {device_id: remote}}}) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport) as client: + with patch( + "coworker.connectors.matrix_crypto_bootstrap.httpx.post", + side_effect=lambda *a, **kw: client.post(*a, **kw), + ): + err = detect_stale_device_keys( + base_url="https://matrix.example.com", + token="tok", + user_id=user_id, + device_id=device_id, + local_curve25519="local_curve", + local_ed25519="local_ed", + ) + assert err and "stale keys" in err + + +def test_detect_stale_device_keys_ok(): + device_id = "DEV123" + user_id = "@bot:example.com" + curve = "same_curve" + ed = "same_ed" + remote = { + "keys": { + f"curve25519:{device_id}": curve, + f"ed25519:{device_id}": ed, + } + } + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"device_keys": {user_id: {device_id: remote}}}) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport) as client: + with patch( + "coworker.connectors.matrix_crypto_bootstrap.httpx.post", + side_effect=lambda *a, **kw: client.post(*a, **kw), + ): + assert ( + detect_stale_device_keys( + base_url="https://matrix.example.com", + token="tok", + user_id=user_id, + device_id=device_id, + local_curve25519=curve, + local_ed25519=ed, + ) + is None + ) + + +def test_bootstrap_cross_signing_uploads_signature(): + storage_key = os.urandom(32) + recovery_key = _encode_recovery_key(storage_key) + key_id = "abc123" + user_id = "@bot:example.com" + device_id = "OWDEVICE" + seed = os.urandom(32) + secret_json = json.dumps({"private_key": _unpadded_b64(seed)}).encode() + enc_blob = _encrypt_secret(storage_key, "m.cross_signing.self_signing", secret_json) + key_desc = _storage_key_self_check(storage_key) + + device_keys = { + "algorithms": ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"], + "device_id": device_id, + "user_id": user_id, + "keys": { + f"curve25519:{device_id}": "curve", + f"ed25519:{device_id}": "ed", + }, + "signatures": {user_id: {f"ed25519:{device_id}": "selfsig"}}, + } + + uploads: list[dict] = [] + + def _resp(status: int, *, json=None, method: str = "GET", url: str = "https://matrix.example.com/x"): + return httpx.Response( + status, + json=json, + request=httpx.Request(method, url), + ) + + def get_handler(url: str, **kwargs): + req_url = str(url) + if "default_key" in req_url: + return _resp(200, json={"key": key_id}, url=req_url) + if f"m.secret_storage.key.{key_id}" in req_url: + return _resp(200, json=key_desc, url=req_url) + if "self_signing" in req_url: + return _resp(200, json={"encrypted": {key_id: enc_blob}}, url=req_url) + return _resp(404, url=req_url) + + def post_handler(url: str, **kwargs): + assert url.endswith("/keys/signatures/upload") + uploads.append(kwargs.get("json") or {}) + return _resp(200, json={}, method="POST", url=url) + + with patch( + "coworker.connectors.matrix_crypto_bootstrap.httpx.get", side_effect=get_handler + ), patch( + "coworker.connectors.matrix_crypto_bootstrap.httpx.post", side_effect=post_handler + ): + bootstrap_cross_signing( + base_url="https://matrix.example.com", + token="tok", + user_id=user_id, + device_id=device_id, + recovery_key=recovery_key, + device_keys=device_keys, + ) + + assert len(uploads) == 1 + signed = uploads[0][user_id][device_id] + assert f"ed25519:{device_id}" in signed["signatures"][user_id] + cross_sigs = [ + k for k in signed["signatures"][user_id] if k != f"ed25519:{device_id}" + ] + assert len(cross_sigs) == 1 + + +@pytest.mark.asyncio +async def test_prepare_matrix_e2ee_requires_recovery_key_when_ssss(): + client = MagicMock() + client.olm = MagicMock() + client.olm.account.identity_keys = {"curve25519": "c", "ed25519": "e"} + client.user_id = "@bot:example.com" + client.device_id = "DEV" + client.should_upload_keys = False + settings = MagicMock( + e2ee_mode="required", + recovery_key=None, + homeserver_url="https://matrix.example.com", + access_token="tok", + ) + with patch( + "coworker.connectors.matrix_crypto_bootstrap.detect_stale_device_keys", + return_value=None, + ), patch( + "coworker.connectors.matrix_crypto_bootstrap._get_account_data", + return_value={"key": "abc"}, + ): + with pytest.raises(MatrixCryptoBootstrapError, match="recovery_key is required"): + await prepare_matrix_e2ee(client, settings) + + +@pytest.mark.asyncio +async def test_prepare_matrix_e2ee_skips_cross_signing_without_ssss(): + client = MagicMock() + client.olm = MagicMock() + client.olm.account.identity_keys = {"curve25519": "c", "ed25519": "e"} + client.user_id = "@bot:example.com" + client.device_id = "DEV" + client.should_upload_keys = False + settings = MagicMock( + e2ee_mode="required", + recovery_key=None, + homeserver_url="https://matrix.example.com", + access_token="tok", + ) + bootstrap = AsyncMock() + with patch( + "coworker.connectors.matrix_crypto_bootstrap.detect_stale_device_keys", + return_value=None, + ), patch( + "coworker.connectors.matrix_crypto_bootstrap._get_account_data", + return_value=None, + ), patch( + "coworker.connectors.matrix_crypto_bootstrap.bootstrap_cross_signing", + bootstrap, + ): + await prepare_matrix_e2ee(client, settings) + bootstrap.assert_not_called() + + +@pytest.mark.asyncio +async def test_prepare_matrix_e2ee_skips_when_disabled(): + client = MagicMock() + settings = MagicMock(e2ee_mode="off", recovery_key=None) + await prepare_matrix_e2ee(client, settings) + client.olm.assert_not_called() + + +@pytest.mark.asyncio +async def test_prepare_matrix_e2ee_stale_keys_fail_closed(): + client = MagicMock() + client.olm.account.identity_keys = {"curve25519": "local", "ed25519": "local_ed"} + client.user_id = "@bot:example.com" + client.device_id = "DEV" + client.should_upload_keys = False + settings = MagicMock( + e2ee_mode="required", + recovery_key="EsT fake", + homeserver_url="https://matrix.example.com", + access_token="tok", + ) + with patch( + "coworker.connectors.matrix_crypto_bootstrap.detect_stale_device_keys", + return_value="stale keys on server", + ), patch( + "coworker.connectors.matrix_crypto_bootstrap.parse_recovery_key", + return_value=b"\x00" * 32, + ): + with pytest.raises(MatrixCryptoBootstrapError, match="stale keys"): + await prepare_matrix_e2ee(client, settings) + + +@pytest.mark.asyncio +async def test_prepare_matrix_e2ee_calls_bootstrap(): + client = MagicMock() + client.olm.account.identity_keys = {"curve25519": "c", "ed25519": "e"} + client.olm._algorithms = ["m.olm.v1.curve25519-aes-sha2"] + client.olm.sign_json.return_value = "sig" + client.user_id = "@bot:example.com" + client.device_id = "DEV" + client.should_upload_keys = False + settings = MagicMock( + e2ee_mode="required", + recovery_key="EsT fake", + homeserver_url="https://matrix.example.com", + access_token="tok", + ) + bootstrap = MagicMock() + with patch( + "coworker.connectors.matrix_crypto_bootstrap.detect_stale_device_keys", + return_value=None, + ), patch( + "coworker.connectors.matrix_crypto_bootstrap._get_account_data", + return_value={"key": "abc"}, + ), patch( + "coworker.connectors.matrix_crypto_bootstrap.bootstrap_cross_signing", + bootstrap, + ): + await prepare_matrix_e2ee(client, settings) + bootstrap.assert_called_once() diff --git a/tests/test_matrix_target.py b/tests/test_matrix_target.py new file mode 100644 index 00000000..e2dab7b4 --- /dev/null +++ b/tests/test_matrix_target.py @@ -0,0 +1,51 @@ +"""Tests for Matrix target encoding — round-trip, invalid input, Slack regression.""" + +from __future__ import annotations + +import pytest + +from coworker.connectors.base import ( + SessionSource, + decode_matrix_target, + encode_matrix_target, + format_target, + parse_target, +) + + +def test_matrix_target_round_trip(): + room = "!abc:example.org" + thread = "$event123:example.org" + enc = encode_matrix_target(room, thread) + assert enc.startswith("matrix/") + assert "/thread/" in enc + assert parse_target(enc) == ("matrix", room, thread) + room_only = encode_matrix_target(room) + assert parse_target(room_only) == ("matrix", room, None) + assert decode_matrix_target(room_only) == (room, None) + assert decode_matrix_target(enc) == (room, thread) + + +def test_matrix_target_invalid(): + for bad in ("matrix/", "matrix/not-b64!!!", "matrix/abc/thread/!!!"): + with pytest.raises(ValueError): + if bad == "matrix/": + decode_matrix_target(bad) + else: + parse_target(bad) + + +def test_slack_telegram_regression(): + assert parse_target("telegram:12345") == ("telegram", "12345", None) + assert parse_target("slack:C1:168.9") == ("slack", "C1", "168.9") + assert format_target("slack", "C1", "168.9") == "slack:C1:168.9" + + +def test_session_source_matrix_target(): + s = SessionSource( + platform="matrix", + chat_id="!room:example.org", + thread_id="$t1:example.org", + ) + assert s.target == encode_matrix_target("!room:example.org", "$t1:example.org") + assert s.target.startswith("matrix/")