From 4717989c1014af020b60de1eddeabd3dc5f03ef5 Mon Sep 17 00:00:00 2001 From: Chris <16943149+nepenth@users.noreply.github.com> Date: Thu, 11 Jun 2026 07:41:43 -0400 Subject: [PATCH] fix(matrix): isolate room context and restore reliable inbound dispatch (#18505) * fix(matrix): isolate room context and inbound dispatch * test(matrix): cover room isolation and dispatch regressions * docs(matrix): document room isolation and session scope * fix(matrix): stabilize CI requirement checks * test(matrix): isolate mautrix stubs in requirements tests * fix(matrix): port room-scoped status and resume to slash commands mixin Move Matrix /status scope output and /resume same-room guards from the pre-refactor gateway/run.py into gateway/slash_commands.py so PR #18505 foundation behavior survives the upstream god-file decomposition. Uses i18n keys for Matrix resume/status messages. Preserves upstream session.py fixes (role_authorized, DM user_id isolation). * docs(matrix): explain inbound dispatch via handle_sync loop Document why Hermes uses an explicit sync loop with handle_sync() rather than client.start(), aligning with upstream #7914 diagnostics while preserving Hermes background maintenance tasks. * fix(i18n): add Matrix resume/status keys to all locale catalogs The Matrix /resume and /status slash-command keys added in the foundation PR must exist in every supported locale file. tests/agent/test_i18n.py asserts key and placeholder parity across catalogs. Non-English locales use English strings as interim placeholders until community translators can localize them. * fix(matrix): restore gateway authz for allowed_users; honor config require_mention Revert the early MATRIX_ALLOWED_USERS gate in _on_room_message so inbound sender authorization stays in gateway authz like main. Parse require_mention from config.extra (platforms.matrix / top-level matrix yaml) with env fallback, matching thread_require_mention and fixing Forge when require_mention is set only in profile config.yaml. * fix(matrix): harden status scope and allowlisted DMs * fix(matrix): use session store lookup for resume scope --- gateway/config.py | 33 +- gateway/platforms/matrix.py | 1690 ++++++++++++++--- gateway/session.py | 27 + gateway/slash_commands.py | 99 +- locales/af.yaml | 14 + locales/de.yaml | 14 + locales/en.yaml | 11 + locales/es.yaml | 14 + locales/fr.yaml | 14 + locales/ga.yaml | 14 + locales/hu.yaml | 14 + locales/it.yaml | 14 + locales/ja.yaml | 14 + locales/ko.yaml | 14 + locales/pt.yaml | 14 + locales/ru.yaml | 14 + locales/tr.yaml | 14 + locales/uk.yaml | 14 + locales/zh-hant.yaml | 14 + locales/zh.yaml | 14 + tests/gateway/test_matrix.py | 1530 ++++++++++++++- ...st_matrix_approval_reaction_fail_closed.py | 39 +- tests/gateway/test_matrix_exec_approval.py | 4 +- .../test_matrix_project_context_isolation.py | 510 +++++ tests/gateway/test_session.py | 24 + .../docs/reference/environment-variables.md | 16 + website/docs/user-guide/messaging/matrix.md | 226 ++- 27 files changed, 4087 insertions(+), 332 deletions(-) create mode 100644 tests/gateway/test_matrix_project_context_isolation.py diff --git a/gateway/config.py b/gateway/config.py index 33df3b1acff..e1c9208749c 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -1218,17 +1218,30 @@ def load_gateway_config() -> GatewayConfig: if isinstance(matrix_cfg, dict): if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"): os.environ["MATRIX_REQUIRE_MENTION"] = str(matrix_cfg["require_mention"]).lower() + allowed_users = matrix_cfg.get("allowed_users") + if allowed_users is not None and not os.getenv("MATRIX_ALLOWED_USERS"): + if isinstance(allowed_users, list): + allowed_users = ",".join(str(v) for v in allowed_users) + os.environ["MATRIX_ALLOWED_USERS"] = str(allowed_users) + allowed_rooms = matrix_cfg.get("allowed_rooms") + if allowed_rooms is not None and not os.getenv("MATRIX_ALLOWED_ROOMS"): + if isinstance(allowed_rooms, list): + allowed_rooms = ",".join(str(v) for v in allowed_rooms) + os.environ["MATRIX_ALLOWED_ROOMS"] = str(allowed_rooms) frc = matrix_cfg.get("free_response_rooms") if frc is not None and not os.getenv("MATRIX_FREE_RESPONSE_ROOMS"): if isinstance(frc, list): frc = ",".join(str(v) for v in frc) os.environ["MATRIX_FREE_RESPONSE_ROOMS"] = str(frc) - # allowed_rooms: if set, bot ONLY responds in these rooms (whitelist) - ar = matrix_cfg.get("allowed_rooms") - if ar is not None and not os.getenv("MATRIX_ALLOWED_ROOMS"): - if isinstance(ar, list): - ar = ",".join(str(v) for v in ar) - os.environ["MATRIX_ALLOWED_ROOMS"] = str(ar) + ignore_patterns = matrix_cfg.get("ignore_user_patterns") + if ignore_patterns is not None and not os.getenv("MATRIX_IGNORE_USER_PATTERNS"): + if isinstance(ignore_patterns, list): + ignore_patterns = ",".join(str(v) for v in ignore_patterns) + os.environ["MATRIX_IGNORE_USER_PATTERNS"] = str(ignore_patterns) + if "process_notices" in matrix_cfg and not os.getenv("MATRIX_PROCESS_NOTICES"): + os.environ["MATRIX_PROCESS_NOTICES"] = str(matrix_cfg["process_notices"]).lower() + if "session_scope" in matrix_cfg and not os.getenv("MATRIX_SESSION_SCOPE"): + os.environ["MATRIX_SESSION_SCOPE"] = str(matrix_cfg["session_scope"]).lower() if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"): os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower() if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"): @@ -1497,8 +1510,14 @@ def _apply_env_overrides(config: GatewayConfig) -> None: matrix_password = os.getenv("MATRIX_PASSWORD", "") if matrix_password: matrix_config.extra["password"] = matrix_password - matrix_e2ee = os.getenv("MATRIX_ENCRYPTION", "").lower() in {"true", "1", "yes"} + matrix_e2ee_mode = os.getenv("MATRIX_E2EE_MODE", "").strip().lower() + matrix_e2ee = ( + matrix_e2ee_mode in ("required", "require", "optional", "prefer", "preferred") + or os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes") + ) matrix_config.extra["encryption"] = matrix_e2ee + if matrix_e2ee_mode: + matrix_config.extra["e2ee_mode"] = matrix_e2ee_mode matrix_device_id = os.getenv("MATRIX_DEVICE_ID", "") if matrix_device_id: matrix_config.extra["device_id"] = matrix_device_id diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index b00fe5effc6..5253c537259 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -10,32 +10,58 @@ Environment variables: MATRIX_USER_ID Full user ID (@bot:server) — required for password login MATRIX_PASSWORD Password (alternative to access token) MATRIX_ENCRYPTION Set "true" to enable E2EE + MATRIX_E2EE_MODE off | optional | required. Overrides MATRIX_ENCRYPTION + when set. Legacy MATRIX_ENCRYPTION=true maps to required. MATRIX_DEVICE_ID Stable device ID for E2EE persistence across restarts MATRIX_PROXY HTTP(S) or SOCKS proxy URL for Matrix traffic MATRIX_ALLOWED_USERS Comma-separated Matrix user IDs (@user:server) + MATRIX_ALLOWED_ROOMS Comma-separated Matrix room IDs allowed to trigger turns MATRIX_HOME_ROOM Room ID for cron/notification delivery MATRIX_REACTIONS Set "false" to disable processing lifecycle reactions (eyes/checkmark/cross). Default: true MATRIX_REQUIRE_MENTION Require @mention in rooms (default: true) - MATRIX_FREE_RESPONSE_ROOMS Comma-separated room IDs exempt from mention requirement (alias of matrix.free_response_rooms) - MATRIX_ALLOWED_ROOMS Comma-separated room IDs; if set, bot ONLY responds in these rooms (whitelist, DMs exempt; alias of matrix.allowed_rooms) + MATRIX_FREE_RESPONSE_ROOMS Comma-separated room IDs exempt from mention requirement + (alias of matrix.free_response_rooms) + MATRIX_ALLOWED_ROOMS Comma-separated room IDs; if set, bot ONLY responds + in these rooms (whitelist, DMs exempt; alias of + matrix.allowed_rooms) + MATRIX_IGNORE_USER_PATTERNS Comma-separated regular expressions for appservice / + bridge ghost user IDs to ignore + MATRIX_PROCESS_NOTICES Set "true" to process inbound m.notice events + (default: false) + MATRIX_ALLOW_ROOM_MENTIONS Allow outbound @room mentions to notify whole rooms + (default: false) + MATRIX_TOOLS_ALLOW_REDACTION + Allow Matrix redaction tool execution (default: false) + MATRIX_TOOLS_ALLOW_INVITES Allow Matrix invite tool execution (default: false) + MATRIX_TOOLS_ALLOW_ROOM_CREATE + Allow Matrix room creation tool execution (default: false) MATRIX_AUTO_THREAD Auto-create threads for room messages (default: true) MATRIX_DM_AUTO_THREAD Auto-create threads for DM messages (default: false) MATRIX_RECOVERY_KEY Recovery key for cross-signing verification after device key rotation MATRIX_DM_MENTION_THREADS Create a thread when bot is @mentioned in a DM (default: false) + MATRIX_ALLOW_PUBLIC_ROOMS Allow Matrix tools to create public rooms (default: false) + MATRIX_APPROVAL_REQUIRE_SENDER + Require reaction controls to come from the original requester + when requester metadata is available (default: true) + MATRIX_APPROVAL_TIMEOUT_SECONDS + Reaction approval/model-picker timeout (default: 300) """ from __future__ import annotations import asyncio +import inspect import logging import mimetypes import os import re import time -from dataclasses import dataclass +from urllib.parse import urlsplit, urlunsplit +from dataclasses import dataclass, field from html import escape as _html_escape +from html.parser import HTMLParser from pathlib import Path from typing import Any, Dict, Optional, Set @@ -177,17 +203,139 @@ def _normalize_matrix_bang_command(text: str) -> str: return f"/{resolved}{match.group(2) or ''}" +class _MatrixHtmlSanitizer(HTMLParser): + """Allowlist sanitizer for Matrix-compatible formatted HTML.""" + + _ALLOWED_TAGS = { + "a", "b", "blockquote", "br", "code", "del", "em", "h1", "h2", "h3", + "h4", "h5", "h6", "hr", "i", "li", "ol", "p", "pre", "s", "strike", + "strong", "ul", + } + _VOID_TAGS = {"br", "hr"} + + def __init__(self) -> None: + super().__init__(convert_charrefs=False) + self._parts: list[str] = [] + self._skip_depth = 0 + + @staticmethod + def _safe_url(value: str) -> str: + stripped = re.sub(r"[\x00-\x1f\x7f]+", "", value or "").strip() + match = re.match(r"^([A-Za-z][A-Za-z0-9+.-]*):", stripped) + scheme = match.group(1).lower() if match else "" + if scheme and scheme not in {"http", "https", "matrix", "mailto"}: + return "" + return stripped + + def _safe_attrs(self, tag: str, attrs: list[tuple[str, str | None]]) -> str: + safe: list[str] = [] + for key, value in attrs: + attr = str(key or "").lower() + raw_value = "" if value is None else str(value) + if attr.startswith("on"): + continue + if tag == "a" and attr == "href": + href = self._safe_url(raw_value) + if href: + safe.append(f' href="{_html_escape(href, quote=True)}"') + elif tag == "code" and attr == "class": + if re.fullmatch(r"language-[A-Za-z0-9_+.-]{1,64}", raw_value): + safe.append(f' class="{_html_escape(raw_value, quote=True)}"') + return "".join(safe) + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + tag = tag.lower() + if tag in {"script", "style"}: + self._skip_depth += 1 + return + if self._skip_depth: + return + if tag not in self._ALLOWED_TAGS: + return + if tag in self._VOID_TAGS: + self._parts.append(f"<{tag}>") + return + self._parts.append(f"<{tag}{self._safe_attrs(tag, attrs)}>") + + def handle_endtag(self, tag: str) -> None: + tag = tag.lower() + if tag in {"script", "style"} and self._skip_depth: + self._skip_depth -= 1 + return + if self._skip_depth or tag not in self._ALLOWED_TAGS or tag in self._VOID_TAGS: + return + self._parts.append(f"") + + def handle_data(self, data: str) -> None: + if not self._skip_depth: + self._parts.append(_html_escape(data)) + + def handle_entityref(self, name: str) -> None: + if not self._skip_depth: + self._parts.append(f"&{name};") + + def handle_charref(self, name: str) -> None: + if not self._skip_depth: + self._parts.append(f"&#{name};") + + def get_html(self) -> str: + return "".join(self._parts) + + +@dataclass(frozen=True) +class MatrixRoomIdentity: + """Resolved Matrix room identity for routing and prompt context.""" + + room_id: str + room_name: str | None + room_topic: str | None + canonical_alias: str | None + server_name: str | None + joined_member_count: int | None + is_direct_account_data: bool + display_name: str + has_explicit_name: bool + chat_type: str + conflict: bool = False + + @dataclass class _MatrixApprovalPrompt: """Tracks a pending Matrix reaction-based exec approval prompt.""" - def __init__(self, session_key: str, chat_id: str, message_id: str, resolved: bool = False): + def __init__( + self, + session_key: str, + chat_id: str, + message_id: str, + resolved: bool = False, + requester_user_id: str | None = None, + expires_at: float | None = None, + ): self.session_key = session_key self.chat_id = chat_id self.message_id = message_id self.resolved = resolved + self.requester_user_id = requester_user_id + self.expires_at = expires_at self.bot_reaction_events: dict[str, str] = {} # emoji -> event_id + +@dataclass +class _MatrixModelPickerPrompt: + """Tracks a pending Matrix reaction-based model picker prompt.""" + + chat_id: str + message_id: str + session_key: str + choices: dict[str, tuple[str, str]] + on_model_selected: Any + requester_user_id: str | None = None + expires_at: float | None = None + resolved: bool = False + bot_reaction_events: dict[str, str] = field(default_factory=dict) + + # Matrix message size limit (4000 chars practical, spec has no hard limit # but clients render poorly above this). MAX_MESSAGE_LENGTH = 4000 @@ -224,6 +372,40 @@ _MATRIX_IMAGE_FILENAME_EXTS = frozenset({ ".avif", }) +_MATRIX_MODEL_PICKER_REACTIONS = ( + "1\ufe0f\u20e3", + "2\ufe0f\u20e3", + "3\ufe0f\u20e3", + "4\ufe0f\u20e3", + "5\ufe0f\u20e3", + "6\ufe0f\u20e3", + "7\ufe0f\u20e3", + "8\ufe0f\u20e3", + "9\ufe0f\u20e3", + "\U0001f51f", +) + +_MATRIX_CAPABILITIES: Dict[str, str] = { + "text": "yes", + "threads": "yes", + "reactions": "yes", + "approvals": "yes", + "model picker": "yes", + "thinking panes": "yes", + "images": "yes", + "multiple images": "yes", + "files": "yes", + "voice/audio": "yes", + "video": "yes", + "E2EE": "off / optional / required", + "diagnostics": "yes", +} + + +def get_matrix_capabilities() -> Dict[str, str]: + """Return Matrix gateway capabilities for docs and release checks.""" + return dict(_MATRIX_CAPABILITIES) + def _looks_like_matrix_image_filename(text: str) -> bool: """Return True when Matrix image body text is probably just a transport filename. @@ -250,6 +432,26 @@ def _looks_like_matrix_image_filename(text: str) -> bool: return suffix in _MATRIX_IMAGE_FILENAME_EXTS +def _matrix_event_timestamp_seconds(event: Any) -> float: + """Return a Matrix event timestamp in seconds, accepting ms or sec values.""" + raw_ts = ( + getattr(event, "timestamp", None) + or getattr(event, "server_timestamp", None) + or 0 + ) + if not raw_ts: + return 0.0 + try: + ts = float(raw_ts) + except (TypeError, ValueError): + return 0.0 + # Matrix origin_server_ts is milliseconds. Some tests/fakes and SDK objects + # expose seconds; do not turn those into 1970-era timestamps. + if ts > 10_000_000_000: + return ts / 1000.0 + return ts + + def _create_matrix_session(proxy_url: str | None): """Create an ``aiohttp.ClientSession`` whose proxy applies to *all* requests. @@ -306,6 +508,159 @@ def _check_e2ee_deps() -> bool: return False +def _normalize_e2ee_mode(value: Any) -> str: + """Normalize Matrix E2EE mode to off/optional/required.""" + raw = str(value or "").strip().lower() + if raw in ("required", "require", "true", "1", "yes", "on"): + return "required" + if raw in ("optional", "prefer", "preferred"): + return "optional" + return "off" + + +def _resolve_e2ee_mode(extra: Optional[Dict[str, Any]] = None) -> str: + """Resolve E2EE mode with MATRIX_ENCRYPTION backwards compatibility.""" + extra = extra or {} + explicit = extra.get("e2ee_mode") or os.getenv("MATRIX_E2EE_MODE", "") + if explicit: + return _normalize_e2ee_mode(explicit) + legacy_enabled = extra.get( + "encryption", + os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes"), + ) + return "required" if legacy_enabled else "off" + + +def _redact_matrix_value(value: Any) -> str: + """Return a safe, non-reversible preview for Matrix diagnostics.""" + text = str(value or "").strip() + if not text: + return "" + return "***" + + +def _write_matrix_recovery_key_output_file(recovery_key: str) -> Optional[Path]: + """Write a generated Matrix recovery key to an operator-chosen file. + + The file is created with mode 0600 and never overwritten. Returns the path + when written, otherwise None. + """ + output_file = os.getenv("MATRIX_RECOVERY_KEY_OUTPUT_FILE", "").strip() + if not output_file: + return None + path = Path(output_file).expanduser() + path.parent.mkdir(parents=True, exist_ok=True) + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + fd = os.open(path, flags, 0o600) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(recovery_key) + fh.write("\n") + except Exception: + try: + os.close(fd) + except OSError: + pass + raise + return path + + +def _get_matrix_recovery_key_output_target() -> tuple[Optional[Path], str]: + """Return a usable one-time recovery-key output path, or a redacted reason.""" + output_file = os.getenv("MATRIX_RECOVERY_KEY_OUTPUT_FILE", "").strip() + if not output_file: + return None, "not_configured" + path = Path(output_file).expanduser() + if path.exists(): + return None, "exists" + try: + path.parent.mkdir(parents=True, exist_ok=True) + except Exception as exc: + return None, f"unusable: {exc}" + return path, "" + + +def _handle_generated_matrix_recovery_key(mxid: str, recovery_key: str) -> None: + """Handle a freshly generated Matrix recovery key without logging it.""" + try: + output_path = _write_matrix_recovery_key_output_file(recovery_key) + except FileExistsError: + logger.warning( + "Matrix: bootstrapped cross-signing for %s. Recovery key output file " + "already exists; refusing to overwrite. Store the generated key " + "securely and set MATRIX_RECOVERY_KEY for future restarts.", + mxid, + ) + return + except Exception as exc: + logger.warning( + "Matrix: bootstrapped cross-signing for %s, but failed to write " + "MATRIX_RECOVERY_KEY_OUTPUT_FILE: %s. Store the generated key " + "securely and set MATRIX_RECOVERY_KEY for future restarts.", + mxid, + exc, + ) + return + + if output_path: + logger.warning( + "Matrix: bootstrapped cross-signing for %s. A new recovery key was " + "written to %s with mode 0600. Move it to your secret store and set " + "MATRIX_RECOVERY_KEY for future restarts.", + mxid, + output_path, + ) + else: + logger.warning( + "Matrix: bootstrapped cross-signing for %s. A new recovery key was " + "generated but will not be logged. Set MATRIX_RECOVERY_KEY_OUTPUT_FILE " + "to write it once with mode 0600, or configure MATRIX_RECOVERY_KEY " + "from your Matrix client before future restarts.", + mxid, + ) + + +def _sanitize_matrix_html(html: str) -> str: + sanitizer = _MatrixHtmlSanitizer() + try: + sanitizer.feed(html or "") + sanitizer.close() + return sanitizer.get_html() + except Exception: + return _html_escape(html or "") + + +def _redact_url_for_log(url: str) -> str: + """Strip query/fragment from URLs before logging signed media links.""" + try: + parts = urlsplit(str(url)) + if not parts.scheme and not parts.netloc: + return str(url).split("?", 1)[0].split("#", 1)[0] + return urlunsplit((parts.scheme, parts.netloc, parts.path, "", "")) + except Exception: + return "" + + +def _pre_sanitize_matrix_markdown(text: str) -> str: + """Remove unsafe raw HTML before Markdown conversion can escape it.""" + result = re.sub( + r"(?is)<\s*(script|style)\b[^>]*>.*?<\s*/\s*\1\s*>", + "", + text or "", + ) + result = re.sub( + r"""(?is)\s+on[a-z0-9_-]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)""", + "", + result, + ) + result = re.sub( + r"""(?is)\s+(href|src)\s*=\s*("[^"]*(?:javascript|data|vbscript):[^"]*"|'[^']*(?:javascript|data|vbscript):[^']*'|[^\s>]*(?:javascript|data|vbscript):[^\s>]*)""", + "", + result, + ) + return result + + def check_matrix_requirements() -> bool: """Return True if the Matrix adapter can be used. @@ -371,21 +726,20 @@ def check_matrix_requirements() -> bool: ) return False - # If encryption is requested, verify E2EE deps are available at startup - # rather than silently degrading to plaintext-only at connect time. - encryption_requested = os.getenv("MATRIX_ENCRYPTION", "").lower() in { - "true", - "1", - "yes", - } - if encryption_requested and not _check_e2ee_deps(): + e2ee_mode = _resolve_e2ee_mode() + if e2ee_mode == "required" and not _check_e2ee_deps(): logger.error( - "Matrix: MATRIX_ENCRYPTION=true but E2EE dependencies are missing. %s. " + "Matrix: E2EE is required but dependencies are missing. %s. " "Without this, encrypted rooms will not work. " - "Set MATRIX_ENCRYPTION=false to disable E2EE.", + "Set MATRIX_E2EE_MODE=off to disable E2EE.", _E2EE_INSTALL_HINT, ) return False + if e2ee_mode == "optional" and not _check_e2ee_deps(): + logger.warning( + "Matrix: E2EE optional but dependencies are missing. %s", + _E2EE_INSTALL_HINT, + ) return True @@ -445,10 +799,8 @@ class MatrixAdapter(BasePlatformAdapter): self._password: str = config.extra.get("password", "") or os.getenv( "MATRIX_PASSWORD", "" ) - self._encryption: bool = config.extra.get( - "encryption", - os.getenv("MATRIX_ENCRYPTION", "").lower() in {"true", "1", "yes"}, - ) + self._e2ee_mode: str = _resolve_e2ee_mode(config.extra) + self._encryption: bool = self._e2ee_mode != "off" self._device_id: str = config.extra.get("device_id", "") or os.getenv( "MATRIX_DEVICE_ID", "" ) @@ -469,9 +821,19 @@ class MatrixAdapter(BasePlatformAdapter): self._late_grace_drops: int = 0 self._late_grace_skew: float = 0.0 self._clock_skew_warned: bool = False + self._last_sync_ts: float = 0.0 # Cache: room_id → bool (is DM) self._dm_rooms: Dict[str, bool] = {} + self._room_identities: Dict[str, MatrixRoomIdentity] = {} + self._room_identity_cached_at: Dict[str, float] = {} + try: + self._room_identity_ttl_seconds = float( + os.getenv("MATRIX_ROOM_IDENTITY_TTL_SECONDS", "60") + ) + except ValueError: + self._room_identity_ttl_seconds = 60.0 + self._room_identity_cache_max = 256 # Set of room IDs we've joined self._joined_rooms: Set[str] = set() # Event deduplication (bounded deque keeps newest entries) @@ -486,10 +848,8 @@ class MatrixAdapter(BasePlatformAdapter): # Thread participation tracking (for require_mention bypass) self._threads = ThreadParticipationTracker("matrix") - # Mention/thread gating — parsed once from env vars. - self._require_mention: bool = os.getenv( - "MATRIX_REQUIRE_MENTION", "true" - ).lower() not in {"false", "0", "no"} + # Mention/thread gating — parsed once from config.extra or env vars. + self._require_mention: bool = self._parse_require_mention(config) self._thread_require_mention: bool = self._parse_thread_require_mention(config) free_rooms_raw = config.extra.get("free_response_rooms") if free_rooms_raw is None: @@ -514,17 +874,27 @@ class MatrixAdapter(BasePlatformAdapter): self._allowed_rooms: Set[str] = { r.strip() for r in str(allowed_rooms_raw).split(",") if r.strip() } - self._auto_thread: bool = os.getenv("MATRIX_AUTO_THREAD", "true").lower() in { + self._allow_room_mentions: bool = os.getenv( + "MATRIX_ALLOW_ROOM_MENTIONS", "false" + ).lower() in ("true", "1", "yes") + self._auto_thread: bool = os.getenv("MATRIX_AUTO_THREAD", "true").lower() in ( "true", "1", "yes", - } + ) self._dm_auto_thread: bool = os.getenv( "MATRIX_DM_AUTO_THREAD", "false" ).lower() in {"true", "1", "yes"} self._dm_mention_threads: bool = os.getenv( "MATRIX_DM_MENTION_THREADS", "false" - ).lower() in {"true", "1", "yes"} + ).lower() in ("true", "1", "yes") + raw_session_scope = os.getenv("MATRIX_SESSION_SCOPE", "auto").strip().lower() + self._matrix_session_scope = ( + raw_session_scope if raw_session_scope in {"auto", "room", "thread"} else "auto" + ) + self._process_notices: bool = os.getenv( + "MATRIX_PROCESS_NOTICES", "false" + ).lower() in ("true", "1", "yes") # Reactions: configurable via MATRIX_REACTIONS (default: true). self._reactions_enabled: bool = os.getenv( @@ -542,6 +912,10 @@ class MatrixAdapter(BasePlatformAdapter): self._proxy_url: str | None = resolve_proxy_url(platform_env_var="MATRIX_PROXY") if self._proxy_url: logger.info("Matrix: proxy configured — %s", self._proxy_url) + try: + self._max_media_bytes = int(os.getenv("MATRIX_MAX_MEDIA_BYTES", str(100 * 1024 * 1024))) + except ValueError: + self._max_media_bytes = 100 * 1024 * 1024 # Text batching: merge rapid successive messages (Telegram-style). # Matrix clients split long messages around 4000 chars. @@ -557,14 +931,41 @@ class MatrixAdapter(BasePlatformAdapter): # Matrix reaction-based dangerous command approvals. self._approval_reaction_map = { "✅": "once", + "♾️": "always", + "♾": "always", + "\u267e\ufe0f": "always", + "\u267e": "always", + "❌": "deny", "❎": "deny", } self._approval_prompts_by_event: Dict[str, _MatrixApprovalPrompt] = {} self._approval_prompt_by_session: Dict[str, str] = {} + self._approval_require_sender: bool = os.getenv( + "MATRIX_APPROVAL_REQUIRE_SENDER", "true" + ).lower() in ("true", "1", "yes") + try: + self._approval_timeout_seconds = int( + os.getenv("MATRIX_APPROVAL_TIMEOUT_SECONDS", "300") + ) + except ValueError: + self._approval_timeout_seconds = 300 + self._model_picker_prompts_by_event: Dict[str, _MatrixModelPickerPrompt] = {} allowed_users_raw = os.getenv("MATRIX_ALLOWED_USERS", "") self._allowed_user_ids: Set[str] = { u.strip() for u in allowed_users_raw.split(",") if u.strip() } + self._allowed_room_ids: Set[str] = set(self._allowed_rooms) + ignore_patterns_raw = os.getenv("MATRIX_IGNORE_USER_PATTERNS", "") + self._ignored_user_patterns: list[re.Pattern[str]] = [] + for pattern in (p.strip() for p in ignore_patterns_raw.split(",") if p.strip()): + try: + self._ignored_user_patterns.append(re.compile(pattern)) + except re.error as exc: + logger.warning( + "Matrix: ignoring invalid MATRIX_IGNORE_USER_PATTERNS entry %r: %s", + pattern, + exc, + ) def _is_duplicate_event(self, event_id) -> bool: """Return True if this event was already processed. Tracks the ID otherwise.""" @@ -579,6 +980,25 @@ class MatrixAdapter(BasePlatformAdapter): self._processed_events_set.add(event_id) return False + @staticmethod + def _parse_require_mention(config) -> bool: + """Parse require_mention from config.extra or env var. + + Handles both YAML booleans and string values (``\"true\"``, ``\"false\"``, + ``\"yes\"``, ``\"no\"``, ``\"on\"``, ``\"off\"``, ``\"1\"``, ``\"0\"``). + Falls back to ``MATRIX_REQUIRE_MENTION`` env var, default ``true``. + """ + configured = config.extra.get("require_mention") + if configured is not None: + if isinstance(configured, bool): + return configured + if isinstance(configured, str): + return configured.lower() not in {"false", "0", "no", "off"} + return bool(configured) + return os.getenv( + "MATRIX_REQUIRE_MENTION", "true" + ).lower() not in {"false", "0", "no", "off"} + @staticmethod def _parse_thread_require_mention(config) -> bool: """Parse thread_require_mention from config.extra or env var. @@ -804,173 +1224,180 @@ class MatrixAdapter(BasePlatformAdapter): # Set up E2EE if requested. if self._encryption: if not _check_e2ee_deps(): - logger.error( - "Matrix: MATRIX_ENCRYPTION=true but E2EE dependencies are missing. %s. " - "Refusing to connect — encrypted rooms would silently fail.", - _E2EE_INSTALL_HINT, - ) - await api.session.close() - return False - try: - from mautrix.crypto import OlmMachine - from mautrix.crypto.store.asyncpg import PgCryptoStore - from mautrix.util.async_db import Database - - _STORE_DIR.mkdir(parents=True, exist_ok=True) - - # Remove legacy pickle file from pre-SQLite era. - legacy_pickle = _STORE_DIR / "crypto_store.pickle" - if legacy_pickle.exists(): - logger.info( - "Matrix: removing legacy crypto_store.pickle (migrated to SQLite)" + if self._e2ee_mode == "optional": + logger.warning( + "Matrix: E2EE optional but dependencies are missing. " + "Continuing without encrypted-room support. %s", + _E2EE_INSTALL_HINT, + ) + self._encryption = False + else: + logger.error( + "Matrix: E2EE is required but dependencies are missing. %s. " + "Refusing to connect — encrypted rooms would silently fail.", + _E2EE_INSTALL_HINT, ) - legacy_pickle.unlink() - - # Open SQLite-backed crypto store. - crypto_db = Database.create( - f"sqlite:///{_CRYPTO_DB_PATH}", - upgrade_table=PgCryptoStore.upgrade_table, - ) - await crypto_db.start() - self._crypto_db = crypto_db - - _acct_id = self._user_id or "hermes" - _pickle_key = f"{_acct_id}:{self._device_id or 'default'}" - crypto_store = PgCryptoStore( - account_id=_acct_id, - pickle_key=_pickle_key, - db=crypto_db, - ) - await crypto_store.open() - - # Bind the store to the runtime device_id before any - # put_account() runs. PgCryptoStore defaults _device_id - # to "" and its crypto_account UPSERT never updates the - # device_id column on conflict — so once put_account - # writes blank, it stays blank forever. That breaks - # every downstream device-scoped olm operation: peer - # to-device ciphertext can't find our identity key and - # no megolm sessions ever land. Setting _device_id here - # (in-memory; the on-disk row may not exist yet) makes - # the first put_account write the correct value. - # DeviceID is a NewType(str) so plain str works at runtime. - if client.device_id: - await crypto_store.put_device_id(client.device_id) - - crypto_state = _CryptoStateStore(state_store, self._joined_rooms) - olm = OlmMachine(client, crypto_store, crypto_state) - - # Accept unverified devices so senders share Megolm - # session keys with us automatically. - olm.share_keys_min_trust = TrustState.UNVERIFIED - olm.send_keys_min_trust = TrustState.UNVERIFIED - - await olm.load() - - # Verify our device keys are still on the homeserver. - if not await self._verify_device_keys_on_server(client, olm): - await crypto_db.stop() await api.session.close() return False - - # Proactively flush one-time keys to detect stale OTK - # conflicts early. When crypto state is wiped but the - # same device ID is reused, the server may still hold OTKs - # signed with the old ed25519 key. Identity key re-upload - # succeeds but OTK uploads fail ("already exists" with - # mismatched signature). Peers then cannot establish Olm - # sessions and all new messages are undecryptable. + if not self._encryption: + pass + else: try: - await olm.share_keys() + from mautrix.crypto import OlmMachine + from mautrix.crypto.store.asyncpg import PgCryptoStore + from mautrix.util.async_db import Database + + _STORE_DIR.mkdir(parents=True, exist_ok=True) except Exception as exc: - exc_str = str(exc) - if "already exists" in exc_str: - logger.error( - "Matrix: device %s has stale one-time keys on the " - "server signed with a previous identity key. " - "Peers cannot establish new Olm sessions with " - "this device. Delete the device from the " - "homeserver and restart, or generate a new " - "access token to get a fresh device ID.", - client.device_id, + if self._e2ee_mode == "optional": + logger.warning( + "Matrix: failed to import optional E2EE client; " + "continuing without encrypted-room support: %s. %s", + exc, + _E2EE_INSTALL_HINT, ) + self._encryption = False + else: + logger.error( + "Matrix: failed to import E2EE client: %s. %s", + exc, + _E2EE_INSTALL_HINT, + ) + await api.session.close() + return False + if self._encryption: + try: + # Remove legacy pickle file from pre-SQLite era. + legacy_pickle = _STORE_DIR / "crypto_store.pickle" + if legacy_pickle.exists(): + logger.info( + "Matrix: removing legacy crypto_store.pickle (migrated to SQLite)" + ) + legacy_pickle.unlink() + + crypto_db = Database.create( + f"sqlite:///{_CRYPTO_DB_PATH}", + upgrade_table=PgCryptoStore.upgrade_table, + ) + await crypto_db.start() + self._crypto_db = crypto_db + + _acct_id = self._user_id or "hermes" + _pickle_key = f"{_acct_id}:{self._device_id or 'default'}" + crypto_store = PgCryptoStore( + account_id=_acct_id, + pickle_key=_pickle_key, + db=crypto_db, + ) + await crypto_store.open() + + if client.device_id: + await crypto_store.put_device_id(client.device_id) + + crypto_state = _CryptoStateStore(state_store, self._joined_rooms) + olm = OlmMachine(client, crypto_store, crypto_state) + olm.share_keys_min_trust = TrustState.UNVERIFIED + olm.send_keys_min_trust = TrustState.UNVERIFIED + + await olm.load() + + if not await self._verify_device_keys_on_server(client, olm): await crypto_db.stop() await api.session.close() return False - # Non-OTK errors are transient (network, etc.) — log - # but allow startup to continue. - logger.warning( - "Matrix: share_keys() warning during startup: %s", - exc, - ) - # Import cross-signing private keys from SSSS and self-sign - # the current device. Required after any device-key rotation - # (fresh crypto.db, share_keys re-upload) — otherwise the - # device's self-signing signature is stale and peers refuse - # to share Megolm sessions with the rotated device. - recovery_key = os.getenv("MATRIX_RECOVERY_KEY", "").strip() - if recovery_key: try: - await olm.verify_with_recovery_key(recovery_key) - logger.info("Matrix: cross-signing verified via recovery key") + await olm.share_keys() except Exception as exc: - logger.warning( - "Matrix: recovery key verification failed: %s", exc - ) - else: - # No recovery key — bootstrap cross-signing if the bot - # has none yet. Without this, Element shows "Encrypted - # by a device not verified by its owner" on every - # message from this bot, indefinitely. mautrix's - # generate_recovery_key does the full flow: generates - # MSK/SSK/USK, uploads private keys to SSSS, publishes - # public keys to the homeserver, and signs the current - # device with the new SSK. Some homeservers require UIA - # for /keys/device_signing/upload — those will need an - # alternate path; Continuwuity and Synapse-with-shared- - # secret accept the unauthenticated upload. - try: - own_xsign = await olm.get_own_cross_signing_public_keys() - except Exception as exc: - own_xsign = None - logger.warning( - "Matrix: cross-signing key lookup failed: %s", exc - ) - if own_xsign is None: + exc_str = str(exc) + if "already exists" in exc_str: + logger.error( + "Matrix: device %s has stale one-time keys on the " + "server signed with a previous identity key. " + "Delete the device from the homeserver and restart, " + "or generate a new access token to get a fresh device ID.", + client.device_id, + ) + await crypto_db.stop() + await api.session.close() + return False + logger.warning("Matrix: share_keys() warning during startup: %s", exc) + + recovery_key = os.getenv("MATRIX_RECOVERY_KEY", "").strip() + if recovery_key: try: - new_recovery_key = await olm.generate_recovery_key() - logger.warning( - "Matrix: bootstrapped cross-signing for %s. " - "SAVE THIS RECOVERY KEY — set " - "MATRIX_RECOVERY_KEY for future restarts so " - "the bot can re-sign its device after key " - "rotation: %s", - client.mxid, - new_recovery_key, - ) + await olm.verify_with_recovery_key(recovery_key) + logger.info("Matrix: cross-signing verified via recovery key") except Exception as exc: - logger.warning( - "Matrix: cross-signing bootstrap failed " - "(non-fatal — Element will show 'not " - "verified by its owner'): %s", - exc, - ) + logger.warning("Matrix: recovery key verification failed: %s", exc) + else: + try: + own_xsign = await olm.get_own_cross_signing_public_keys() + except Exception as exc: + own_xsign = None + logger.warning("Matrix: cross-signing key lookup failed: %s", exc) + if own_xsign is None: + _, output_error = _get_matrix_recovery_key_output_target() + if output_error == "not_configured": + logger.warning( + "Matrix: cross-signing keys are missing, but " + "automatic bootstrap is skipped because " + "MATRIX_RECOVERY_KEY_OUTPUT_FILE is not configured. " + "Configure MATRIX_RECOVERY_KEY from your Matrix client " + "or set MATRIX_RECOVERY_KEY_OUTPUT_FILE to write a new " + "recovery key once with mode 0600." + ) + elif output_error == "exists": + logger.warning( + "Matrix: cross-signing keys are missing, but " + "automatic bootstrap is skipped because " + "MATRIX_RECOVERY_KEY_OUTPUT_FILE already exists and " + "will not be overwritten." + ) + elif output_error: + logger.warning( + "Matrix: cross-signing keys are missing, but " + "automatic bootstrap is skipped because " + "MATRIX_RECOVERY_KEY_OUTPUT_FILE is not usable: %s", + output_error, + ) + else: + try: + new_recovery_key = await olm.generate_recovery_key() + _handle_generated_matrix_recovery_key( + str(client.mxid), + new_recovery_key, + ) + except Exception as exc: + logger.warning( + "Matrix: cross-signing bootstrap failed " + "(non-fatal — Element will show 'not verified by its owner'): %s", + exc, + ) - client.crypto = olm - logger.info( - "Matrix: E2EE enabled (store: %s%s)", - str(_CRYPTO_DB_PATH), - f", device_id={client.device_id}" if client.device_id else "", - ) - except Exception as exc: - logger.error( - "Matrix: failed to create E2EE client: %s. %s", - exc, - _E2EE_INSTALL_HINT, - ) - await api.session.close() - return False + client.crypto = olm + logger.info( + "Matrix: E2EE enabled (store: %s%s)", + str(_CRYPTO_DB_PATH), + f", device_id={client.device_id}" if client.device_id else "", + ) + except Exception as exc: + if self._e2ee_mode == "optional": + logger.warning( + "Matrix: failed to create optional E2EE client; " + "continuing without encrypted-room support: %s. %s", + exc, + _E2EE_INSTALL_HINT, + ) + self._encryption = False + else: + logger.error( + "Matrix: failed to create E2EE client: %s. %s", + exc, + _E2EE_INSTALL_HINT, + ) + await api.session.close() + return False # Register event handlers. from mautrix.client import InternalEventType as IntEvt @@ -995,9 +1422,12 @@ class MatrixAdapter(BasePlatformAdapter): try: sync_data = await client.sync(timeout=10000, full_state=True) if isinstance(sync_data, dict): + self._last_sync_ts = time.time() rooms_join = sync_data.get("rooms", {}).get("join", {}) self._joined_rooms.clear() self._joined_rooms.update(rooms_join.keys()) + self._room_identities.clear() + self._room_identity_cached_at.clear() # Store the next_batch token so incremental syncs start # from where the initial sync left off. nb = sync_data.get("next_batch") @@ -1013,9 +1443,7 @@ class MatrixAdapter(BasePlatformAdapter): # Dispatch events from the initial sync so the OlmMachine # receives to-device key shares queued while we were offline. try: - tasks = client.handle_sync(sync_data) - if tasks: - await asyncio.gather(*tasks) + await self._dispatch_sync(sync_data) except Exception as exc: logger.warning("Matrix: initial sync event dispatch error: %s", exc) await self._join_pending_invites(sync_data) @@ -1093,20 +1521,7 @@ class MatrixAdapter(BasePlatformAdapter): for i, chunk in enumerate(chunks): msg_content = self._build_text_message_content(chunk) - # Reply-to support. - if reply_to: - msg_content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to}} - - # Thread support: if metadata has thread_id, send as threaded reply. - thread_id = (metadata or {}).get("thread_id") - if thread_id: - relates_to = msg_content.get("m.relates_to", {}) - relates_to["rel_type"] = "m.thread" - relates_to["event_id"] = thread_id - relates_to["is_falling_back"] = True - if reply_to and "m.in_reply_to" not in relates_to: - relates_to["m.in_reply_to"] = {"event_id": reply_to} - msg_content["m.relates_to"] = relates_to + self._apply_relation_metadata(msg_content, reply_to=reply_to, metadata=metadata) try: event_id = await asyncio.wait_for( @@ -1153,21 +1568,56 @@ class MatrixAdapter(BasePlatformAdapter): async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: """Return room name and type (dm/group).""" - name = chat_id - chat_type = "dm" if await self._is_dm_room(chat_id) else "group" + identity = await self._resolve_room_identity(chat_id) + chat_type = "dm" if identity.chat_type == "dm" else "group" + return {"name": identity.display_name, "type": chat_type} - if self._client: - try: - name_evt = await self._client.get_state_event( - RoomID(chat_id), - EventType.ROOM_NAME, - ) - if name_evt and hasattr(name_evt, "name") and name_evt.name: - name = name_evt.name - except Exception: - pass - - return {"name": name, "type": chat_type} + def get_diagnostics(self) -> Dict[str, Any]: + """Return redacted Matrix readiness/status diagnostics.""" + now = time.time() + token_present = bool(self._access_token) + user_id = self._user_id or getattr(self._client, "mxid", "") or "" + device_id = self._device_id or getattr(self._client, "device_id", "") or "" + return { + "platform": "matrix", + "homeserver": self._homeserver, + "auth": { + "access_token_present": token_present, + "password_present": bool(self._password), + "token_preview": "***" if token_present else "", + "user_id": user_id, + "device_id_present": bool(device_id), + "device_id_preview": _redact_matrix_value(device_id), + }, + "sync": { + "connected": self._client is not None, + "joined_room_count": len(self._joined_rooms), + "last_sync_age_seconds": ( + max(0.0, now - self._last_sync_ts) if self._last_sync_ts else None + ), + }, + "e2ee": { + "mode": self._e2ee_mode, + "enabled": bool(self._encryption), + "deps_available": _check_e2ee_deps(), + "crypto_store_path": str(_CRYPTO_DB_PATH), + "recovery_key_configured": bool(os.getenv("MATRIX_RECOVERY_KEY", "").strip()), + }, + "policy": { + "allowed_user_count": len(self._allowed_user_ids), + "allowed_room_count": len(self._allowed_room_ids), + "ignored_user_pattern_count": len(self._ignored_user_patterns), + "require_mention": self._require_mention, + "free_response_room_count": len(self._free_rooms), + "allow_room_mentions": self._allow_room_mentions, + "process_notices": self._process_notices, + "allow_public_rooms": os.getenv("MATRIX_ALLOW_PUBLIC_ROOMS", "").lower() + in ("true", "1", "yes"), + }, + "media": { + "max_media_bytes": self._max_media_bytes, + }, + } # ------------------------------------------------------------------ # Optional overrides @@ -1242,43 +1692,120 @@ class MatrixAdapter(BasePlatformAdapter): ) try: - # Try aiohttp first (always available), fall back to httpx - try: - import aiohttp as _aiohttp - _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(self._proxy_url) - async with _aiohttp.ClientSession(**_sess_kw) as http: - async with http.get( - image_url, - timeout=_aiohttp.ClientTimeout(total=30), - **_req_kw, - ) as resp: - resp.raise_for_status() - data = await resp.read() - ct = resp.content_type or "image/png" - fname = ( - image_url.rsplit("/", 1)[-1].split("?")[0] or "image.png" - ) - except ImportError: - import httpx - _httpx_kw: dict = {} - if self._proxy_url: - _httpx_kw["proxy"] = self._proxy_url - async with httpx.AsyncClient(**_httpx_kw) as http: - resp = await http.get(image_url, follow_redirects=True, timeout=30) - resp.raise_for_status() - data = resp.content - ct = resp.headers.get("content-type", "image/png") - fname = image_url.rsplit("/", 1)[-1].split("?")[0] or "image.png" + data, ct, fname = await self._download_external_media_with_cap(image_url) except Exception as exc: - logger.warning("Matrix: failed to download image %s: %s", image_url, exc) + logger.warning( + "Matrix: failed to download image %s: %s", + _redact_url_for_log(image_url), + exc, + ) + fallback = ( + "I couldn't download and upload the image to Matrix. " + "The source URL was not shown because it may contain private tokens." + ) + if caption: + fallback = f"{caption}\n{fallback}" return await self.send( - chat_id, f"{caption or ''}\n{image_url}".strip(), reply_to + chat_id, + fallback, + reply_to, ) return await self._upload_and_send( chat_id, data, fname, ct, "m.image", caption, reply_to, metadata ) + async def _download_external_media_with_cap(self, url: str) -> tuple[bytes, str, str]: + """Download external media while enforcing redirect safety and size caps.""" + from tools.url_safety import is_safe_url + + if not is_safe_url(url): + raise ValueError("blocked unsafe media URL") + + def _check_content_length(headers: Any) -> None: + raw = None + try: + raw = headers.get("Content-Length") or headers.get("content-length") + except Exception: + raw = None + if raw is None: + return + try: + size = int(raw) + except (TypeError, ValueError): + return + if size > self._max_media_bytes: + raise ValueError( + f"media exceeds Matrix limit ({size} > {self._max_media_bytes} bytes)" + ) + + def _check_image_content_type(content_type: str) -> str: + content_type = str(content_type or "").split(";", 1)[0].strip().lower() + if not content_type.startswith("image/"): + raise ValueError("external media is not an image") + return content_type + + def _append_chunk(parts: list[bytes], total: int, chunk: bytes) -> int: + total += len(chunk) + if total > self._max_media_bytes: + raise ValueError( + f"media exceeds Matrix limit (> {self._max_media_bytes} bytes)" + ) + parts.append(chunk) + return total + + fname = url.rsplit("/", 1)[-1].split("?")[0] or "image.png" + + try: + import aiohttp as _aiohttp + + _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(self._proxy_url) + async with _aiohttp.ClientSession(**_sess_kw) as http: + async with http.get( + url, + timeout=_aiohttp.ClientTimeout(total=30), + allow_redirects=True, + **_req_kw, + ) as resp: + resp.raise_for_status() + if not is_safe_url(str(resp.url)): + raise ValueError("blocked unsafe redirect URL") + _check_content_length(resp.headers) + parts: list[bytes] = [] + total = 0 + async for chunk in resp.content.iter_chunked(65536): + total = _append_chunk(parts, total, bytes(chunk)) + ct = _check_image_content_type( + getattr(resp, "content_type", None) + or resp.headers.get("content-type", "application/octet-stream") + ) + return b"".join(parts), ct, fname + except ImportError: + import httpx + + _httpx_kw: dict = {} + if self._proxy_url: + _httpx_kw["proxy"] = self._proxy_url + async with httpx.AsyncClient(**_httpx_kw) as http: + async with http.stream( + "GET", + url, + follow_redirects=True, + timeout=30, + ) as resp: + resp.raise_for_status() + if not is_safe_url(str(resp.url)): + raise ValueError("blocked unsafe redirect URL") + _check_content_length(resp.headers) + parts: list[bytes] = [] + total = 0 + async for chunk in resp.aiter_bytes(): + total = _append_chunk(parts, total, bytes(chunk)) + ct = _check_image_content_type( + resp.headers.get("content-type", "application/octet-stream") + ) + return b"".join(parts), ct, fname + async def send_image_file( self, chat_id: str, @@ -1292,6 +1819,42 @@ class MatrixAdapter(BasePlatformAdapter): chat_id, image_path, "m.image", caption, reply_to, metadata=metadata ) + async def send_multiple_images( + self, + chat_id: str, + images: list[tuple[str, str]], + metadata: Optional[Dict[str, Any]] = None, + human_delay: float = 0.0, + ) -> None: + """Send multiple Matrix images as one ordered logical batch.""" + if not images: + return + from urllib.parse import unquote as _unquote + + total = len(images) + for idx, (image_url, alt_text) in enumerate(images, start=1): + if human_delay > 0 and idx > 1: + await asyncio.sleep(human_delay) + caption = alt_text or None + if total > 1 and caption: + caption = f"{caption} ({idx}/{total})" + if image_url.startswith("file://"): + result = await self.send_image_file( + chat_id=chat_id, + image_path=_unquote(image_url[7:]), + caption=caption, + metadata=metadata, + ) + else: + result = await self.send_image( + chat_id=chat_id, + image_url=image_url, + caption=caption, + metadata=metadata, + ) + if not result.success: + logger.warning("Matrix: failed to send image %d/%d: %s", idx, total, result.error) + async def send_document( self, chat_id: str, @@ -1350,6 +1913,7 @@ class MatrixAdapter(BasePlatformAdapter): if not self._client: return SendResult(success=False, error="Not connected") + requester_user_id = str((metadata or {}).get("requester_user_id") or "") or None cmd_preview = command[:2000] + "..." if len(command) > 2000 else command text = ( "⚠️ **Dangerous command requires approval**\n" @@ -1370,6 +1934,8 @@ class MatrixAdapter(BasePlatformAdapter): session_key=session_key, chat_id=chat_id, message_id=result.message_id, + requester_user_id=requester_user_id, + expires_at=time.monotonic() + max(self._approval_timeout_seconds, 0), ) old_event = self._approval_prompt_by_session.get(session_key) if old_event: @@ -1377,7 +1943,7 @@ class MatrixAdapter(BasePlatformAdapter): self._approval_prompts_by_event[result.message_id] = prompt self._approval_prompt_by_session[session_key] = result.message_id - for emoji in ("✅", "❎"): + for emoji in ("✅", "♾️", "❌"): try: reaction_result = await self._send_reaction(chat_id, result.message_id, emoji) # Save the bot's reaction event_id for later cleanup @@ -1388,6 +1954,87 @@ class MatrixAdapter(BasePlatformAdapter): return result + async def send_model_picker( + self, + chat_id: str, + providers: list, + current_model: str, + current_provider: str, + session_key: str, + on_model_selected, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a Matrix reaction-based model picker.""" + if not self._client: + return SendResult(success=False, error="Not connected") + + flat_choices: list[tuple[str, str, str, str]] = [] + for provider in providers or []: + provider_slug = str(provider.get("slug") or "") + provider_name = str(provider.get("name") or provider_slug) + models = provider.get("models") or [] + for model_id in models: + if len(flat_choices) >= len(_MATRIX_MODEL_PICKER_REACTIONS): + break + flat_choices.append(( + _MATRIX_MODEL_PICKER_REACTIONS[len(flat_choices)], + str(model_id), + provider_slug, + provider_name, + )) + if len(flat_choices) >= len(_MATRIX_MODEL_PICKER_REACTIONS): + break + + if not flat_choices: + return await self.send( + chat_id, + "No authenticated models are available for this session.", + metadata=metadata, + ) + + try: + from hermes_cli.providers import get_label + provider_label = get_label(current_provider) + except Exception: + provider_label = current_provider + + lines = [ + "⚙ **Model Configuration**", + f"Current model: `{current_model or 'unknown'}`", + f"Provider: {provider_label or 'unknown'}", + "", + "React to choose a model:", + ] + choices: dict[str, tuple[str, str]] = {} + for emoji, model_id, provider_slug, provider_name in flat_choices: + choices[emoji] = (model_id, provider_slug) + lines.append(f"{emoji} `{model_id}` — {provider_name}") + + result = await self.send(chat_id, "\n".join(lines), metadata=metadata) + if not result.success or not result.message_id: + return result + + prompt = _MatrixModelPickerPrompt( + chat_id=chat_id, + message_id=result.message_id, + session_key=session_key, + choices=choices, + on_model_selected=on_model_selected, + requester_user_id=str((metadata or {}).get("requester_user_id") or "") or None, + expires_at=time.monotonic() + max(self._approval_timeout_seconds, 0), + ) + self._model_picker_prompts_by_event[result.message_id] = prompt + + for emoji in choices: + try: + reaction_event_id = await self._send_reaction(chat_id, result.message_id, emoji) + if reaction_event_id: + prompt.bot_reaction_events[emoji] = str(reaction_event_id) + except Exception as exc: + logger.debug("Matrix: failed to add model picker reaction %s: %s", emoji, exc) + + return result + def format_message(self, content: str) -> str: """Pass-through — Matrix supports standard Markdown natively.""" # Strip image markdown; media is uploaded separately. @@ -1411,6 +2058,11 @@ class MatrixAdapter(BasePlatformAdapter): is_voice: bool = False, ) -> SendResult: """Upload bytes to Matrix and send as a media message.""" + if len(data) > self._max_media_bytes: + return SendResult( + success=False, + error=f"Media file exceeds Matrix limit ({len(data)} > {self._max_media_bytes} bytes)", + ) upload_data = data encrypted_file = None @@ -1461,16 +2113,7 @@ class MatrixAdapter(BasePlatformAdapter): if is_voice: msg_content["org.matrix.msc3245.voice"] = {} - if reply_to: - msg_content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to}} - - thread_id = (metadata or {}).get("thread_id") - if thread_id: - relates_to = msg_content.get("m.relates_to", {}) - relates_to["rel_type"] = "m.thread" - relates_to["event_id"] = thread_id - relates_to["is_falling_back"] = True - msg_content["m.relates_to"] = relates_to + self._apply_relation_metadata(msg_content, reply_to=reply_to, metadata=metadata) try: event_id = await self._client.send_message_event( @@ -1499,6 +2142,15 @@ class MatrixAdapter(BasePlatformAdapter): return await self.send( room_id, f"{caption or ''}\n(file not found: {file_path})", reply_to ) + try: + file_size = p.stat().st_size + except OSError: + file_size = 0 + if file_size > self._max_media_bytes: + return SendResult( + success=False, + error=f"Media file exceeds Matrix limit ({file_size} > {self._max_media_bytes} bytes)", + ) fname = file_name or p.name ct = mimetypes.guess_type(fname)[0] or "application/octet-stream" @@ -1543,10 +2195,13 @@ class MatrixAdapter(BasePlatformAdapter): return if isinstance(sync_data, dict): + self._last_sync_ts = time.time() # Update joined rooms from sync response. rooms_join = sync_data.get("rooms", {}).get("join", {}) if rooms_join: self._joined_rooms.update(rooms_join.keys()) + self._room_identities.clear() + self._room_identity_cached_at.clear() # Advance the sync token so the next request is # incremental instead of a full initial sync. @@ -1558,9 +2213,7 @@ class MatrixAdapter(BasePlatformAdapter): # Dispatch events to registered handlers so that # _on_room_message / _on_reaction / _on_invite fire. try: - tasks = client.handle_sync(sync_data) - if tasks: - await asyncio.gather(*tasks) + await self._dispatch_sync(sync_data) except Exception as exc: logger.warning("Matrix: sync event dispatch error: %s", exc) await self._join_pending_invites(sync_data) @@ -1589,6 +2242,17 @@ class MatrixAdapter(BasePlatformAdapter): # Event callbacks # ------------------------------------------------------------------ + async def _dispatch_sync(self, sync_data: Dict[str, Any]) -> None: + """Dispatch a sync response through the mautrix event machinery.""" + client = self._client + if not client or not hasattr(client, "handle_sync"): + return + tasks = client.handle_sync(sync_data) + if inspect.isawaitable(tasks): + tasks = await tasks + if tasks: + await asyncio.gather(*tasks) + def _is_self_sender(self, sender: str) -> bool: """Return True if the sender refers to the bot's own account. @@ -1645,6 +2309,33 @@ class MatrixAdapter(BasePlatformAdapter): return True return localpart.startswith("_") + def _matches_ignored_user_pattern(self, sender: str) -> bool: + """Return True when sender matches configured Matrix ignore patterns.""" + return any(pattern.search(sender or "") for pattern in self._ignored_user_patterns) + + def _is_allowed_matrix_room(self, room_id: str) -> bool: + """Return True when MATRIX_ALLOWED_ROOMS permits the room.""" + return not self._allowed_room_ids or room_id in self._allowed_room_ids + + async def _is_allowed_matrix_room_event(self, room_id: str) -> bool: + """Return True when a room event may proceed past intake filters. + + MATRIX_ALLOWED_ROOMS constrains shared rooms. Matrix DMs are exempt so + personal chats still work when operators use a room allowlist for + project rooms. + """ + if self._is_allowed_matrix_room(room_id): + return True + try: + return await self._is_dm_room(room_id) + except Exception as exc: + logger.debug( + "Matrix: could not resolve room identity for allowlist check in %s: %s", + room_id, + exc, + ) + return False + async def _on_room_message(self, event: Any) -> None: """Handle incoming room message events (text, media).""" room_id = str(getattr(event, "room_id", "")) @@ -1676,6 +2367,19 @@ class MatrixAdapter(BasePlatformAdapter): room_id, ) return + if self._matches_ignored_user_pattern(sender): + logger.debug( + "Matrix: ignoring sender %s in %s due to configured ignore pattern", + sender, + room_id, + ) + return + if not await self._is_allowed_matrix_room_event(room_id): + logger.info( + "Matrix: ignoring message from unauthorized room %s", + room_id, + ) + return # Deduplicate by event ID. event_id = str(getattr(event, "event_id", "")) @@ -1683,12 +2387,7 @@ class MatrixAdapter(BasePlatformAdapter): return # Startup grace: ignore old messages from initial sync. - raw_ts = ( - getattr(event, "timestamp", None) - or getattr(event, "server_timestamp", None) - or 0 - ) - event_ts = raw_ts / 1000.0 if raw_ts else 0.0 + event_ts = _matrix_event_timestamp_seconds(event) if event_ts and event_ts < self._startup_ts - _STARTUP_GRACE_SECONDS: # If we are well past startup but events are still being dropped # by the grace check, the host clock is probably set ahead of @@ -1764,7 +2463,7 @@ class MatrixAdapter(BasePlatformAdapter): # Ignore m.notice to prevent bot-to-bot loops (m.notice is the # conventional msgtype for bot responses in the Matrix ecosystem). - if msgtype == "m.notice": + if msgtype == "m.notice" and not self._process_notices: return # Dispatch by msgtype. @@ -1773,7 +2472,7 @@ class MatrixAdapter(BasePlatformAdapter): await self._handle_media_message( room_id, sender, event_id, event_ts, source_content, relates_to, msgtype ) - elif msgtype == "m.text": + elif msgtype in ("m.text", "m.notice"): await self._handle_text_message( room_id, sender, event_id, event_ts, source_content, relates_to ) @@ -1792,6 +2491,7 @@ class MatrixAdapter(BasePlatformAdapter): Returns (body, is_dm, chat_type, thread_id, display_name, source) or None if the message should be dropped (mention gating). """ + identity = await self._resolve_room_identity(room_id) is_dm = await self._is_dm_room(room_id) chat_type = "dm" if is_dm else "group" @@ -1858,18 +2558,34 @@ class MatrixAdapter(BasePlatformAdapter): if is_mentioned and self._require_mention: body = self._strip_mention(body) - # Auto-thread. - if not thread_id and ((not is_dm and self._auto_thread) or (is_dm and self._dm_auto_thread)): - thread_id = event_id - self._threads.mark(thread_id) + # Auto-thread/session-scope policy. Real Matrix thread roots are + # preserved above; synthetic thread roots are policy-driven. + if not thread_id: + if is_dm: + if self._dm_auto_thread: + thread_id = event_id + self._threads.mark(thread_id) + elif self._matrix_session_scope == "room": + thread_id = None + elif self._matrix_session_scope == "thread": + thread_id = event_id + self._threads.mark(thread_id) + elif self._auto_thread: + thread_id = event_id + self._threads.mark(thread_id) display_name = await self._get_display_name(room_id, sender) source = self.build_source( chat_id=room_id, + chat_name=identity.display_name, chat_type=chat_type, user_id=sender, user_name=display_name, thread_id=thread_id, + chat_topic=identity.room_topic, + guild_id=identity.server_name, + parent_chat_id=room_id if thread_id else None, + message_id=event_id, ) if thread_id: @@ -1964,6 +2680,12 @@ class MatrixAdapter(BasePlatformAdapter): """Process a media message event (image, audio, video, file).""" body = source_content.get("body", "") or "" url = source_content.get("url", "") + if url and not str(url).startswith("mxc://"): + logger.warning( + "[Matrix] Rejecting inbound media %s with non-MXC URL", + event_id, + ) + return # Convert mxc:// to HTTP URL for downstream processing. http_url = "" @@ -1975,11 +2697,30 @@ class MatrixAdapter(BasePlatformAdapter): if not isinstance(content_info, dict): content_info = {} event_mimetype = content_info.get("mimetype", "") + event_size = content_info.get("size") + try: + event_size_int = int(event_size) if event_size is not None else 0 + except (TypeError, ValueError): + event_size_int = 0 + if event_size_int and event_size_int > self._max_media_bytes: + logger.warning( + "[Matrix] Rejecting oversized inbound media %s (%d > %d bytes)", + event_id, + event_size_int, + self._max_media_bytes, + ) + return # For encrypted media, the URL may be in file.url. file_content = source_content.get("file", {}) if not url and isinstance(file_content, dict): url = file_content.get("url", "") or "" + if url and not str(url).startswith("mxc://"): + logger.warning( + "[Matrix] Rejecting inbound encrypted media %s with non-MXC URL", + event_id, + ) + return if url and url.startswith("mxc://"): http_url = self._mxc_to_http(url) @@ -2150,6 +2891,8 @@ class MatrixAdapter(BasePlatformAdapter): try: await self._client.join_room(RoomID(room_id)) self._joined_rooms.add(room_id) + self._room_identities.pop(room_id, None) + self._room_identity_cached_at.pop(room_id, None) logger.info("Matrix: joined %s", room_id) await self._refresh_dm_cache() return True @@ -2319,15 +3062,20 @@ class MatrixAdapter(BasePlatformAdapter): if prompt and not prompt.resolved: if room_id != prompt.chat_id: return - _allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"} - if not _allow_all and not (self._allowed_user_ids and sender in self._allowed_user_ids): - logger.info( - "Matrix: ignoring approval reaction from unauthorized user %s on %s", - sender, reacts_to, - ) + if self._matrix_prompt_expired(prompt): + await self._expire_matrix_approval_prompt(room_id, reacts_to, prompt) + return + if not await self._validate_matrix_prompt_reactor( + room_id, reacts_to, sender, prompt, "approval" + ): return choice = self._approval_reaction_map.get(key) if not choice: + await self._send_invalid_reaction_feedback( + room_id, + reacts_to, + "That reaction is not valid for this approval prompt.", + ) return try: from tools.approval import resolve_gateway_approval @@ -2346,17 +3094,157 @@ class MatrixAdapter(BasePlatformAdapter): await self._redact_bot_approval_reactions(room_id, prompt) except Exception as exc: logger.error("Failed to resolve gateway approval from Matrix reaction: %s", exc) + return + + model_prompt = self._model_picker_prompts_by_event.get(reacts_to) + if model_prompt and not model_prompt.resolved: + if room_id != model_prompt.chat_id: + return + if self._matrix_prompt_expired(model_prompt): + await self._expire_matrix_model_picker_prompt(room_id, reacts_to, model_prompt) + return + if not await self._validate_matrix_prompt_reactor( + room_id, reacts_to, sender, model_prompt, "model picker" + ): + return + selection = model_prompt.choices.get(key) + if not selection: + await self._send_invalid_reaction_feedback( + room_id, + reacts_to, + "That reaction is not one of the available model choices.", + ) + return + model_prompt.resolved = True + self._model_picker_prompts_by_event.pop(reacts_to, None) + model_id, provider_slug = selection + try: + confirmation = await model_prompt.on_model_selected( + room_id, model_id, provider_slug + ) + await self._redact_bot_model_picker_reactions(room_id, model_prompt) + if confirmation: + await self.send(room_id, confirmation, reply_to=reacts_to) + except Exception as exc: + logger.error("Failed to switch model from Matrix reaction: %s", exc) + await self.send( + room_id, + f"Failed to switch model: {exc}", + reply_to=reacts_to, + ) + return + + def _matrix_prompt_expired(self, prompt: Any) -> bool: + expires_at = getattr(prompt, "expires_at", None) + return expires_at is not None and time.monotonic() > float(expires_at) + + async def _validate_matrix_prompt_reactor( + self, + room_id: str, + target_event_id: str, + sender: str, + prompt: Any, + prompt_label: str, + ) -> bool: + allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in { + "true", + "1", + "yes", + } + if not allow_all and not ( + self._allowed_user_ids and sender in self._allowed_user_ids + ): + logger.info( + "Matrix: ignoring %s reaction from unauthorized user %s on %s", + prompt_label, sender, target_event_id, + ) + await self._send_invalid_reaction_feedback( + room_id, + target_event_id, + "Only an authorized Matrix user can use these controls.", + ) + return False + + requester = getattr(prompt, "requester_user_id", None) + approval_require_sender = getattr(self, "_approval_require_sender", True) + if approval_require_sender and requester and sender != requester: + logger.info( + "Matrix: ignoring %s reaction from %s; requester is %s", + prompt_label, sender, requester, + ) + await self._send_invalid_reaction_feedback( + room_id, + target_event_id, + "Only the user who requested this action can use these controls.", + ) + return False + return True + + async def _send_invalid_reaction_feedback( + self, + room_id: str, + target_event_id: str, + text: str, + ) -> None: + try: + await self.send(room_id, text, reply_to=target_event_id) + except Exception as exc: + logger.debug("Matrix: failed to send invalid reaction feedback: %s", exc) + + async def _expire_matrix_approval_prompt( + self, + room_id: str, + target_event_id: str, + prompt: "_MatrixApprovalPrompt", + ) -> None: + prompt.resolved = True + self._approval_prompts_by_event.pop(target_event_id, None) + self._approval_prompt_by_session.pop(prompt.session_key, None) + await self._redact_bot_approval_reactions(room_id, prompt) + await self._send_invalid_reaction_feedback( + room_id, + target_event_id, + "This approval prompt has expired. Run the command again if you still want to approve it.", + ) + + async def _expire_matrix_model_picker_prompt( + self, + room_id: str, + target_event_id: str, + prompt: "_MatrixModelPickerPrompt", + ) -> None: + prompt.resolved = True + self._model_picker_prompts_by_event.pop(target_event_id, None) + await self._redact_bot_model_picker_reactions(room_id, prompt) + await self._send_invalid_reaction_feedback( + room_id, + target_event_id, + "This model picker has expired. Run `/model` again to choose a model.", + ) async def _redact_bot_approval_reactions( self, room_id: str, prompt: "_MatrixApprovalPrompt", ) -> None: - """Redact the bot's seed ✅/❎ reactions, leaving only the user's reaction.""" + """Redact the bot's seeded approval reactions, leaving only the user's reaction.""" for emoji, evt_id in prompt.bot_reaction_events.items(): self._schedule_reaction_redaction(room_id, evt_id, "approval resolved") logger.debug("Matrix: scheduled bot reaction redaction %s (%s)", emoji, evt_id) + async def _redact_bot_model_picker_reactions( + self, + room_id: str, + prompt: "_MatrixModelPickerPrompt", + ) -> None: + """Redact the bot's seeded model picker reactions.""" + for emoji, evt_id in prompt.bot_reaction_events.items(): + try: + await self.redact_message(room_id, evt_id, "model picker resolved") + logger.debug("Matrix: redacted model picker reaction %s (%s)", emoji, evt_id) + except Exception as exc: + logger.debug("Matrix: failed to redact model picker reaction %s: %s", emoji, exc) + # ------------------------------------------------------------------ # Text message aggregation (handles Matrix client-side splits) # ------------------------------------------------------------------ @@ -2505,6 +3393,13 @@ class MatrixAdapter(BasePlatformAdapter): """Create a new Matrix room.""" if not self._client: return None + if preset == "public_chat" and os.getenv("MATRIX_ALLOW_PUBLIC_ROOMS", "").lower() not in ( + "true", + "1", + "yes", + ): + logger.warning("Matrix: refusing to create public room without MATRIX_ALLOW_PUBLIC_ROOMS=true") + return None try: preset_enum = { "private_chat": RoomCreatePreset.PRIVATE, @@ -2539,6 +3434,63 @@ class MatrixAdapter(BasePlatformAdapter): logger.warning("Matrix: invite error: %s", exc) return False + async def fetch_history( + self, + room_id: str, + limit: int = 20, + from_token: str = "", + ) -> list[dict[str, Any]]: + """Fetch recent Matrix room history using the live client.""" + if not self._client: + return [] + limit = max(1, min(int(limit or 20), 100)) + try: + direction = getattr(PaginationDirection, "BACKWARD", "b") + if hasattr(self._client, "messages"): + response = await self._client.messages( + RoomID(room_id), + from_token=SyncToken(from_token) if from_token else None, + direction=direction, + limit=limit, + ) + elif hasattr(self._client, "get_messages"): + response = await self._client.get_messages( + RoomID(room_id), + start=SyncToken(from_token) if from_token else None, + direction=direction, + limit=limit, + ) + else: + logger.debug("Matrix: client has no messages/get_messages method") + return [] + chunk = getattr(response, "chunk", None) + if chunk is None and isinstance(response, dict): + chunk = response.get("chunk") + return [self._serialize_history_event(evt) for evt in (chunk or [])] + except Exception as exc: + logger.warning("Matrix: fetch history error: %s", exc) + return [] + + def _serialize_history_event(self, event: Any) -> dict[str, Any]: + content = getattr(event, "content", None) + if content is None and isinstance(event, dict): + content = event.get("content", {}) + if not isinstance(content, dict): + content = dict(content) if hasattr(content, "items") else {} + return { + "event_id": str( + getattr(event, "event_id", "") + or (event.get("event_id", "") if isinstance(event, dict) else "") + ), + "sender": str( + getattr(event, "sender", "") + or (event.get("sender", "") if isinstance(event, dict) else "") + ), + "timestamp": _matrix_event_timestamp_seconds(event), + "msgtype": str(content.get("msgtype", "")), + "body": str(content.get("body", "")), + } + # ------------------------------------------------------------------ # Presence # ------------------------------------------------------------------ @@ -2598,22 +3550,152 @@ class MatrixAdapter(BasePlatformAdapter): # Helpers # ------------------------------------------------------------------ - async def _is_dm_room(self, room_id: str) -> bool: - """Check if a room is a DM.""" - if self._dm_rooms.get(room_id, False): - return True - # Fallback: check member count via state store. + @staticmethod + def _state_event_value(event: Any, key: str) -> Optional[str]: + """Extract a simple value from a Matrix state event object or dict.""" + if event is None: + return None + value = getattr(event, key, None) + if value: + return str(value) + if isinstance(event, dict): + if event.get(key): + return str(event[key]) + content = event.get("content") + if isinstance(content, dict) and content.get(key): + return str(content[key]) + content = getattr(event, "content", None) + if isinstance(content, dict) and content.get(key): + return str(content[key]) + if content is not None and getattr(content, key, None): + return str(getattr(content, key)) + return None + + async def _get_room_member_count(self, room_id: str) -> Optional[int]: state_store = ( getattr(self._client, "state_store", None) if self._client else None ) - if state_store: - try: - members = await state_store.get_members(room_id) - if members and len(members) == 2: - return True - except Exception: - pass - return False + if not state_store: + return None + try: + members = await state_store.get_members(room_id) + except Exception: + return None + if members is None: + return None + try: + return len(members) + except TypeError: + return None + + async def _get_room_name(self, room_id: str) -> Optional[str]: + if not self._client or not hasattr(self._client, "get_state_event"): + return None + try: + event = await self._client.get_state_event( + RoomID(room_id), + "m.room.name", + ) + except Exception: + return None + value = self._state_event_value(event, "name") + return value.strip() if value and value.strip() else None + + async def _get_room_canonical_alias(self, room_id: str) -> Optional[str]: + if not self._client or not hasattr(self._client, "get_state_event"): + return None + try: + event = await self._client.get_state_event( + RoomID(room_id), + "m.room.canonical_alias", + ) + except Exception: + return None + value = self._state_event_value(event, "alias") + return value.strip() if value and value.strip() else None + + async def _get_room_topic(self, room_id: str) -> Optional[str]: + if not self._client or not hasattr(self._client, "get_state_event"): + return None + try: + event = await self._client.get_state_event( + RoomID(room_id), + "m.room.topic", + ) + except Exception: + return None + value = self._state_event_value(event, "topic") + return value.strip() if value and value.strip() else None + + @staticmethod + def _room_server_name(room_id: str) -> Optional[str]: + if ":" not in room_id: + return None + server = room_id.rsplit(":", 1)[-1].strip() + return server or None + + def _cache_room_identity(self, room_id: str, identity: MatrixRoomIdentity) -> None: + if len(self._room_identities) >= self._room_identity_cache_max: + oldest = min( + self._room_identity_cached_at, + key=self._room_identity_cached_at.get, + default=None, + ) + if oldest: + self._room_identities.pop(oldest, None) + self._room_identity_cached_at.pop(oldest, None) + self._room_identities[room_id] = identity + self._room_identity_cached_at[room_id] = time.monotonic() + + async def _resolve_room_identity( + self, + room_id: str, + *, + force_refresh: bool = False, + ) -> MatrixRoomIdentity: + """Resolve Matrix room identity without member-count DM heuristics. + + Matrix ``m.direct`` account data is the authoritative DM signal, but + explicitly named rooms win over stale/conflicting DM account data. + """ + cached = self._room_identities.get(room_id) + cached_at = self._room_identity_cached_at.get(room_id, 0.0) + cache_fresh = ( + self._room_identity_ttl_seconds <= 0 + or time.monotonic() - cached_at <= self._room_identity_ttl_seconds + ) + if cached is not None and cache_fresh and not force_refresh: + return cached + + room_name = await self._get_room_name(room_id) + room_topic = await self._get_room_topic(room_id) + canonical_alias = await self._get_room_canonical_alias(room_id) + member_count = await self._get_room_member_count(room_id) + has_explicit_name = bool(room_name) + is_direct = bool(self._dm_rooms.get(room_id, False)) + conflict = bool(is_direct and has_explicit_name) + chat_type = "dm" if is_direct and not has_explicit_name else "room" + display_name = room_name or canonical_alias or room_id + + identity = MatrixRoomIdentity( + room_id=room_id, + room_name=room_name, + room_topic=room_topic, + canonical_alias=canonical_alias, + server_name=self._room_server_name(room_id), + joined_member_count=member_count, + is_direct_account_data=is_direct, + display_name=display_name, + has_explicit_name=has_explicit_name, + chat_type=chat_type, + conflict=conflict, + ) + self._cache_room_identity(room_id, identity) + return identity + + async def _is_dm_room(self, room_id: str) -> bool: + """Check if a room is a DM.""" + return (await self._resolve_room_identity(room_id)).chat_type == "dm" async def _refresh_dm_cache(self) -> None: """Refresh the DM room cache from m.direct account data.""" @@ -2637,9 +3719,11 @@ class MatrixAdapter(BasePlatformAdapter): dm_room_ids: Set[str] = set() for user_id, rooms in dm_data.items(): if isinstance(rooms, list): - dm_room_ids.update(str(r) for r in rooms) + dm_room_ids.update(str(r) for r in rooms if isinstance(r, str)) self._dm_rooms = {rid: (rid in dm_room_ids) for rid in self._joined_rooms} + self._room_identities.clear() + self._room_identity_cached_at.clear() # ------------------------------------------------------------------ # Mention detection helpers @@ -2649,8 +3733,11 @@ class MatrixAdapter(BasePlatformAdapter): """Build Matrix text content with HTML and outbound mention metadata.""" msg_content: Dict[str, Any] = {"msgtype": msgtype, "body": text} mention_user_ids = self._extract_outbound_mentions(text) + room_mentioned = self._allow_room_mentions and self._has_outbound_room_mention(text) if mention_user_ids: msg_content["m.mentions"] = {"user_ids": mention_user_ids} + if room_mentioned: + msg_content.setdefault("m.mentions", {})["room"] = True html_source = self._inject_outbound_mention_links(text) html = self._markdown_to_html(html_source) @@ -2660,6 +3747,31 @@ class MatrixAdapter(BasePlatformAdapter): return msg_content + def _apply_relation_metadata( + self, + msg_content: Dict[str, Any], + *, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> None: + """Apply Matrix reply/thread relation metadata to an outbound payload.""" + thread_id = str((metadata or {}).get("thread_id") or "") + if reply_to: + msg_content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to}} + if thread_id: + relates_to = msg_content.get("m.relates_to", {}) + relates_to["rel_type"] = "m.thread" + relates_to["event_id"] = thread_id + relates_to["is_falling_back"] = True + # Matrix clients that do not render threads still use reply + # fallback. If no explicit reply target is available, fall back + # to the thread root. + relates_to.setdefault( + "m.in_reply_to", + {"event_id": reply_to or thread_id}, + ) + msg_content["m.relates_to"] = relates_to + def _extract_outbound_mentions(self, text: str) -> list[str]: """Return unique Matrix user IDs mentioned in outbound text.""" protected, _ = self._protect_outbound_mention_regions(text) @@ -2672,6 +3784,11 @@ class MatrixAdapter(BasePlatformAdapter): mentions.append(user_id) return mentions + def _has_outbound_room_mention(self, text: str) -> bool: + """Return True when outbound text contains @room outside protected spans.""" + protected, _ = self._protect_outbound_mention_regions(text) + return bool(re.search(r"(? str: """Wrap outbound Matrix mentions in markdown links outside code spans.""" if not text: @@ -2812,6 +3929,7 @@ class MatrixAdapter(BasePlatformAdapter): links, blockquotes, lists, and horizontal rules — everything the Matrix HTML spec allows. """ + text = _pre_sanitize_matrix_markdown(text) try: import markdown as _md @@ -2826,11 +3944,11 @@ class MatrixAdapter(BasePlatformAdapter): if html.count("

") == 1: html = html.replace("

", "").replace("

", "") - return html + return _sanitize_matrix_html(html) except ImportError: pass - return self._markdown_to_html_fallback(text) + return _sanitize_matrix_html(self._markdown_to_html_fallback(text)) # ------------------------------------------------------------------ # Regex-based Markdown -> HTML (no extra dependencies) diff --git a/gateway/session.py b/gateway/session.py index 19aa0cdb776..5548139a682 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -294,6 +294,22 @@ def build_session_context_prompt( if context.source.chat_topic: lines.append(f"**Channel Topic:** {context.source.chat_topic}") + if context.source.platform == Platform.MATRIX: + src = context.source + room_name = src.chat_name or src.chat_id + room_id = _hash_chat_id(src.chat_id) if redact_pii else src.chat_id + lines.append("") + lines.append(f"**Matrix Room:** {room_name}") + lines.append(f"**Matrix Room ID:** {room_id}") + if src.thread_id: + thread_id = _hash_chat_id(src.thread_id) if redact_pii else src.thread_id + lines.append(f"**Matrix Thread:** {thread_id}") + lines.append( + "**Matrix room boundary:** Treat this turn as scoped to the current " + "Matrix room/thread only. Do not assume unresolved references are " + "about other Matrix rooms or projects unless the user explicitly says so." + ) + # User identity. # In shared multi-user sessions (shared threads OR shared non-thread groups # when group_sessions_per_user=False), multiple users contribute to the same @@ -1264,6 +1280,17 @@ class SessionStore: entries.sort(key=lambda e: e.updated_at, reverse=True) return entries + + def lookup_by_session_id(self, session_id: str) -> Optional[SessionEntry]: + """Return the active session entry for a persisted session ID, if any.""" + if not session_id: + return None + with self._lock: + self._ensure_loaded_locked() + for entry in self._entries.values(): + if entry.session_id == session_id: + return entry + return None def append_to_transcript(self, session_id: str, message: Dict[str, Any], skip_db: bool = False) -> None: """Append a message to a session's transcript (SQLite). diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 107b5645ec5..1bb2fc41d1c 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -17,6 +17,7 @@ from __future__ import annotations import asyncio import dataclasses +import hashlib import inspect import logging import os @@ -32,7 +33,7 @@ from agent.account_usage import fetch_account_usage, render_account_usage_lines from agent.i18n import t from gateway.config import HomeChannel, Platform, PlatformConfig from gateway.platforms.base import EphemeralReply, MessageEvent, MessageType -from gateway.session import build_session_key +from gateway.session import SessionSource, build_session_key from hermes_cli.config import cfg_get from utils import ( atomic_json_write, @@ -447,6 +448,22 @@ class GatewaySlashCommandsMixin: ]) if queue_depth: lines.append(t("gateway.status.queued", count=queue_depth)) + if source.platform == Platform.MATRIX: + adapter = self.adapters.get(Platform.MATRIX) + scope = getattr(adapter, "_matrix_session_scope", os.getenv("MATRIX_SESSION_SCOPE", "auto")) + thread = source.thread_id or "none" + lines.extend([ + "", + t("gateway.status.matrix_scope_header"), + t("gateway.status.matrix_scope_room", room=source.chat_name or source.chat_id), + t("gateway.status.matrix_scope_room_id", room_id=source.chat_id), + t("gateway.status.matrix_scope_thread", thread_id=thread), + t("gateway.status.matrix_scope_mode", scope=scope), + t( + "gateway.status.matrix_scope_key", + session_key=self._redact_matrix_session_key(session_key), + ), + ]) lines.extend([ "", t("gateway.status.platforms", platforms=', '.join(connected_platforms)), @@ -454,6 +471,37 @@ class GatewaySlashCommandsMixin: return "\n".join(lines) + @staticmethod + def _redact_matrix_session_key(session_key: str) -> str: + """Return a stable Matrix session-key fingerprint for shared room status.""" + text = str(session_key or "") + digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:12] + return f"sha256:{digest}" + + def _gateway_session_origin_for_id(self, session_id: str) -> Optional[SessionSource]: + """Best-effort origin lookup for gateway session IDs.""" + lookup = getattr(type(self.session_store), "lookup_by_session_id", None) + if callable(lookup): + entry = lookup(self.session_store, session_id) + return getattr(entry, "origin", None) if entry is not None else None + + # Test doubles and older stores may not expose the public lookup helper. + # Keep the Matrix resume guard fail-closed if no origin can be resolved. + entries = getattr(self.session_store, "_entries", {}) or {} + for entry in entries.values(): + if getattr(entry, "session_id", None) == session_id: + return getattr(entry, "origin", None) + return None + + @staticmethod + def _same_matrix_room(current: SessionSource, origin: Optional[SessionSource]) -> bool: + return ( + origin is not None + and origin.platform == Platform.MATRIX + and current.platform == Platform.MATRIX + and origin.chat_id == current.chat_id + ) + async def _handle_agents_command(self, event: MessageEvent) -> str: """Handle /agents command - list active agents and running tasks.""" from gateway.run import _AGENT_PENDING_SENTINEL @@ -2652,7 +2700,14 @@ class GatewaySlashCommandsMixin: source = event.source session_key = self._session_key_for_source(source) - name = event.get_command_args().strip() + raw_args = event.get_command_args().strip() + try: + parts = shlex.split(raw_args) + except ValueError as exc: + return t("gateway.resume.parse_error", error=exc) + allow_all = "--all" in parts + allow_cross_room = "--cross-room" in parts + name = " ".join(p for p in parts if p not in {"--all", "--cross-room"}).strip() # Strip common outer brackets/quotes users may type literally from the # usage hint (e.g. ``/resume ``). Mirrors the CLI behavior. @@ -2673,11 +2728,24 @@ class GatewaySlashCommandsMixin: # List recent titled sessions for this user/platform try: titled = _list_titled_sessions() + if source.platform == Platform.MATRIX and not allow_all: + scoped = [] + for s in titled: + origin = self._gateway_session_origin_for_id(str(s.get("id") or "")) + if self._same_matrix_room(source, origin): + scoped.append(s) + titled = scoped if not titled: + if source.platform == Platform.MATRIX and not allow_all: + return t("gateway.resume.matrix_no_named_sessions") return t("gateway.resume.no_named_sessions") lines = [t("gateway.resume.list_header")] for idx, s in enumerate(titled[:10], start=1): title = s["title"] + if source.platform == Platform.MATRIX and allow_all: + origin = self._gateway_session_origin_for_id(str(s.get("id") or "")) + if origin: + title = f"{title} — {origin.chat_name or origin.chat_id}" preview = s.get("preview", "")[:40] preview_part = t("gateway.resume.list_preview_suffix", preview=preview) if preview else "" lines.append(t("gateway.resume.list_item_numbered", index=idx, title=title, preview_part=preview_part)) @@ -2691,6 +2759,13 @@ class GatewaySlashCommandsMixin: if name.isdigit(): try: titled = _list_titled_sessions() + if source.platform == Platform.MATRIX and not allow_all: + scoped = [] + for s in titled: + origin = self._gateway_session_origin_for_id(str(s.get("id") or "")) + if self._same_matrix_room(source, origin): + scoped.append(s) + titled = scoped except Exception as e: logger.debug("Failed to list titled sessions for numeric resume: %s", e) return t("gateway.resume.list_failed", error=e) @@ -2717,6 +2792,17 @@ class GatewaySlashCommandsMixin: except Exception as e: logger.debug("Failed to resolve resume continuation for %s: %s", target_id, e) + if source.platform == Platform.MATRIX: + target_origin = self._gateway_session_origin_for_id(target_id) + if not self._same_matrix_room(source, target_origin) and not allow_cross_room: + if target_origin is None: + return t("gateway.resume.matrix_blocked_no_origin", name=name) + return t( + "gateway.resume.matrix_blocked_other_room", + room=target_origin.chat_name or target_origin.chat_id, + name=name, + ) + # Check if already on that session current_entry = self.session_store.get_or_create_session(source) if current_entry.session_id == target_id: @@ -2744,6 +2830,15 @@ class GatewaySlashCommandsMixin: # Count messages for context history = self.session_store.load_transcript(target_id) msg_count = len([m for m in history if m.get("role") == "user"]) if history else 0 + msg_part = f" ({msg_count} message{'s' if msg_count != 1 else ''})" if msg_count else "" + + if source.platform == Platform.MATRIX and allow_cross_room: + return t( + "gateway.resume.matrix_cross_room_success", + title=title, + room=source.chat_name or source.chat_id, + msg_part=msg_part, + ) if not msg_count: return t("gateway.resume.resumed_no_count", title=title) if msg_count == 1: diff --git a/locales/af.yaml b/locales/af.yaml index bb3fae463ee..1ac315a1d4d 100644 --- a/locales/af.yaml +++ b/locales/af.yaml @@ -219,6 +219,14 @@ gateway: resume: db_unavailable: "Sessie-databasis is nie beskikbaar nie." + parse_error: "⚠️ Could not parse `/resume` arguments: {error}. +Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`." + matrix_no_named_sessions: "No named sessions found for this Matrix room. +Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room ` to explicitly cross room boundaries." + matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries." + matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here." + matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**. +Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}" no_named_sessions: "Geen benoemde sessies gevind nie.\nGebruik `/title My Sessie` om jou huidige sessie 'n naam te gee, en dan `/resume My Sessie` om later daarheen terug te keer." list_header: "📋 **Benoemde Sessies**\n" list_item: "• **{title}**{preview_part}" @@ -251,6 +259,12 @@ gateway: status: header: "📊 **Hermes Gateway Status**" + matrix_scope_header: "**Matrix scope:**" + matrix_scope_room: " room: {room}" + matrix_scope_room_id: " room_id: {room_id}" + matrix_scope_thread: " thread_id: {thread_id}" + matrix_scope_mode: " session_scope: {scope}" + matrix_scope_key: " session_key: {session_key}" session_id: "**Sessie-ID:** `{session_id}`" title: "**Titel:** {title}" created: "**Geskep:** {timestamp}" diff --git a/locales/de.yaml b/locales/de.yaml index 437a90a9476..f83181c9815 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -219,6 +219,14 @@ gateway: resume: db_unavailable: "Sitzungsdatenbank nicht verfügbar." + parse_error: "⚠️ Could not parse `/resume` arguments: {error}. +Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`." + matrix_no_named_sessions: "No named sessions found for this Matrix room. +Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room ` to explicitly cross room boundaries." + matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries." + matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here." + matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**. +Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}" no_named_sessions: "Keine benannten Sitzungen gefunden.\nVerwenden Sie `/title Meine Sitzung`, um die aktuelle Sitzung zu benennen, dann `/resume Meine Sitzung`, um später dorthin zurückzukehren." list_header: "📋 **Benannte Sitzungen**\n" list_item: "• **{title}**{preview_part}" @@ -251,6 +259,12 @@ gateway: status: header: "📊 **Hermes-Gateway-Status**" + matrix_scope_header: "**Matrix scope:**" + matrix_scope_room: " room: {room}" + matrix_scope_room_id: " room_id: {room_id}" + matrix_scope_thread: " thread_id: {thread_id}" + matrix_scope_mode: " session_scope: {scope}" + matrix_scope_key: " session_key: {session_key}" session_id: "**Sitzungs-ID:** `{session_id}`" title: "**Titel:** {title}" created: "**Erstellt:** {timestamp}" diff --git a/locales/en.yaml b/locales/en.yaml index 1516977ccb6..acf15ae1a12 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -234,6 +234,11 @@ gateway: resume: db_unavailable: "Session database not available." + parse_error: "⚠️ Could not parse `/resume` arguments: {error}.\nUse quotes around titles with spaces, for example: `/resume \"Project A Plan\"`." + matrix_no_named_sessions: "No named sessions found for this Matrix room.\nUse `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room ` to explicitly cross room boundaries." + matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries." + matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here." + matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.\nFuture messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}" no_named_sessions: "No named sessions found.\nUse `/title My Session` to name your current session, then `/resume My Session` to return to it later." list_header: "📋 **Named Sessions**\n" list_item: "• **{title}**{preview_part}" @@ -266,6 +271,12 @@ gateway: status: header: "📊 **Hermes Gateway Status**" + matrix_scope_header: "**Matrix scope:**" + matrix_scope_room: " room: {room}" + matrix_scope_room_id: " room_id: {room_id}" + matrix_scope_thread: " thread_id: {thread_id}" + matrix_scope_mode: " session_scope: {scope}" + matrix_scope_key: " session_key: {session_key}" session_id: "**Session ID:** `{session_id}`" title: "**Title:** {title}" created: "**Created:** {timestamp}" diff --git a/locales/es.yaml b/locales/es.yaml index b22fc2ec429..429f9f0f987 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -219,6 +219,14 @@ gateway: resume: db_unavailable: "Base de datos de sesiones no disponible." + parse_error: "⚠️ Could not parse `/resume` arguments: {error}. +Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`." + matrix_no_named_sessions: "No named sessions found for this Matrix room. +Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room ` to explicitly cross room boundaries." + matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries." + matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here." + matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**. +Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}" no_named_sessions: "No se encontraron sesiones con nombre.\nUsa `/title Mi sesión` para nombrar la sesión actual y luego `/resume Mi sesión` para volver a ella." list_header: "📋 **Sesiones con nombre**\n" list_item: "• **{title}**{preview_part}" @@ -251,6 +259,12 @@ gateway: status: header: "📊 **Estado de Hermes Gateway**" + matrix_scope_header: "**Matrix scope:**" + matrix_scope_room: " room: {room}" + matrix_scope_room_id: " room_id: {room_id}" + matrix_scope_thread: " thread_id: {thread_id}" + matrix_scope_mode: " session_scope: {scope}" + matrix_scope_key: " session_key: {session_key}" session_id: "**ID de sesión:** `{session_id}`" title: "**Título:** {title}" created: "**Creado:** {timestamp}" diff --git a/locales/fr.yaml b/locales/fr.yaml index 8201df6c3f3..ad17ee61fa9 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -219,6 +219,14 @@ gateway: resume: db_unavailable: "Base de données des sessions indisponible." + parse_error: "⚠️ Could not parse `/resume` arguments: {error}. +Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`." + matrix_no_named_sessions: "No named sessions found for this Matrix room. +Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room ` to explicitly cross room boundaries." + matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries." + matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here." + matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**. +Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}" no_named_sessions: "Aucune session nommée trouvée.\nUtilisez `/title Ma session` pour nommer la session actuelle, puis `/resume Ma session` pour y revenir plus tard." list_header: "📋 **Sessions nommées**\n" list_item: "• **{title}**{preview_part}" @@ -251,6 +259,12 @@ gateway: status: header: "📊 **État de Hermes Gateway**" + matrix_scope_header: "**Matrix scope:**" + matrix_scope_room: " room: {room}" + matrix_scope_room_id: " room_id: {room_id}" + matrix_scope_thread: " thread_id: {thread_id}" + matrix_scope_mode: " session_scope: {scope}" + matrix_scope_key: " session_key: {session_key}" session_id: "**ID de session :** `{session_id}`" title: "**Titre :** {title}" created: "**Créé :** {timestamp}" diff --git a/locales/ga.yaml b/locales/ga.yaml index eaf957a2912..8acb02e3814 100644 --- a/locales/ga.yaml +++ b/locales/ga.yaml @@ -223,6 +223,14 @@ gateway: resume: db_unavailable: "Níl bunachar sonraí na seisiún ar fáil." + parse_error: "⚠️ Could not parse `/resume` arguments: {error}. +Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`." + matrix_no_named_sessions: "No named sessions found for this Matrix room. +Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room ` to explicitly cross room boundaries." + matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries." + matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here." + matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**. +Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}" no_named_sessions: "Níor aimsíodh aon seisiún ainmnithe.\nÚsáid `/title M'Ainm Seisiúin` chun do sheisiún reatha a ainmniú, ansin `/resume M'Ainm Seisiúin` chun filleadh air níos déanaí." list_header: "📋 **Seisiúin Ainmnithe**\n" list_item: "• **{title}**{preview_part}" @@ -255,6 +263,12 @@ gateway: status: header: "📊 **Stádas Hermes Gateway**" + matrix_scope_header: "**Matrix scope:**" + matrix_scope_room: " room: {room}" + matrix_scope_room_id: " room_id: {room_id}" + matrix_scope_thread: " thread_id: {thread_id}" + matrix_scope_mode: " session_scope: {scope}" + matrix_scope_key: " session_key: {session_key}" session_id: "**ID Seisiúin:** `{session_id}`" title: "**Teideal:** {title}" created: "**Cruthaithe:** {timestamp}" diff --git a/locales/hu.yaml b/locales/hu.yaml index 78b18ac1942..8afe07bba47 100644 --- a/locales/hu.yaml +++ b/locales/hu.yaml @@ -219,6 +219,14 @@ gateway: resume: db_unavailable: "A munkamenet-adatbázis nem érhető el." + parse_error: "⚠️ Could not parse `/resume` arguments: {error}. +Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`." + matrix_no_named_sessions: "No named sessions found for this Matrix room. +Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room ` to explicitly cross room boundaries." + matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries." + matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here." + matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**. +Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}" no_named_sessions: "Nem található elnevezett munkamenet.\nHasználd a `/title Saját munkamenet` parancsot a jelenlegi munkamenet elnevezéséhez, majd a `/resume Saját munkamenet` paranccsal térhetsz vissza hozzá." list_header: "📋 **Elnevezett munkamenetek**\n" list_item: "• **{title}**{preview_part}" @@ -251,6 +259,12 @@ gateway: status: header: "📊 **Hermes Gateway állapot**" + matrix_scope_header: "**Matrix scope:**" + matrix_scope_room: " room: {room}" + matrix_scope_room_id: " room_id: {room_id}" + matrix_scope_thread: " thread_id: {thread_id}" + matrix_scope_mode: " session_scope: {scope}" + matrix_scope_key: " session_key: {session_key}" session_id: "**Munkamenet-azonosító:** `{session_id}`" title: "**Cím:** {title}" created: "**Létrehozva:** {timestamp}" diff --git a/locales/it.yaml b/locales/it.yaml index 89d4e0796bb..2e355c94c68 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -219,6 +219,14 @@ gateway: resume: db_unavailable: "Database delle sessioni non disponibile." + parse_error: "⚠️ Could not parse `/resume` arguments: {error}. +Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`." + matrix_no_named_sessions: "No named sessions found for this Matrix room. +Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room ` to explicitly cross room boundaries." + matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries." + matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here." + matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**. +Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}" no_named_sessions: "Nessuna sessione con nome trovata.\nUsa `/title My Session` per dare un nome alla sessione attuale, poi `/resume My Session` per tornare a essa in seguito." list_header: "📋 **Sessioni con nome**\n" list_item: "• **{title}**{preview_part}" @@ -251,6 +259,12 @@ gateway: status: header: "📊 **Stato del Gateway Hermes**" + matrix_scope_header: "**Matrix scope:**" + matrix_scope_room: " room: {room}" + matrix_scope_room_id: " room_id: {room_id}" + matrix_scope_thread: " thread_id: {thread_id}" + matrix_scope_mode: " session_scope: {scope}" + matrix_scope_key: " session_key: {session_key}" session_id: "**ID sessione:** `{session_id}`" title: "**Titolo:** {title}" created: "**Creata:** {timestamp}" diff --git a/locales/ja.yaml b/locales/ja.yaml index 1758746df02..d860684acf2 100644 --- a/locales/ja.yaml +++ b/locales/ja.yaml @@ -219,6 +219,14 @@ gateway: resume: db_unavailable: "セッションデータベースは利用できません。" + parse_error: "⚠️ Could not parse `/resume` arguments: {error}. +Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`." + matrix_no_named_sessions: "No named sessions found for this Matrix room. +Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room ` to explicitly cross room boundaries." + matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries." + matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here." + matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**. +Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}" no_named_sessions: "名前付きセッションが見つかりません。\n`/title セッション名` で現在のセッションに名前を付けると、後で `/resume セッション名` で戻れます。" list_header: "📋 **名前付きセッション**\n" list_item: "• **{title}**{preview_part}" @@ -251,6 +259,12 @@ gateway: status: header: "📊 **Hermes ゲートウェイ状態**" + matrix_scope_header: "**Matrix scope:**" + matrix_scope_room: " room: {room}" + matrix_scope_room_id: " room_id: {room_id}" + matrix_scope_thread: " thread_id: {thread_id}" + matrix_scope_mode: " session_scope: {scope}" + matrix_scope_key: " session_key: {session_key}" session_id: "**セッション ID:** `{session_id}`" title: "**タイトル:** {title}" created: "**作成日時:** {timestamp}" diff --git a/locales/ko.yaml b/locales/ko.yaml index 19fbd28cb30..0966fb22ce2 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -219,6 +219,14 @@ gateway: resume: db_unavailable: "세션 데이터베이스를 사용할 수 없습니다." + parse_error: "⚠️ Could not parse `/resume` arguments: {error}. +Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`." + matrix_no_named_sessions: "No named sessions found for this Matrix room. +Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room ` to explicitly cross room boundaries." + matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries." + matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here." + matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**. +Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}" no_named_sessions: "이름이 지정된 세션이 없습니다.\n현재 세션에 이름을 지정하려면 `/title 내 세션`을 사용하고, 나중에 `/resume 내 세션`으로 돌아오세요." list_header: "📋 **이름이 지정된 세션**\n" list_item: "• **{title}**{preview_part}" @@ -251,6 +259,12 @@ gateway: status: header: "📊 **Hermes 게이트웨이 상태**" + matrix_scope_header: "**Matrix scope:**" + matrix_scope_room: " room: {room}" + matrix_scope_room_id: " room_id: {room_id}" + matrix_scope_thread: " thread_id: {thread_id}" + matrix_scope_mode: " session_scope: {scope}" + matrix_scope_key: " session_key: {session_key}" session_id: "**세션 ID:** `{session_id}`" title: "**제목:** {title}" created: "**생성됨:** {timestamp}" diff --git a/locales/pt.yaml b/locales/pt.yaml index 191ad1413ec..fa74c6f90e9 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -219,6 +219,14 @@ gateway: resume: db_unavailable: "Base de dados de sessões indisponível." + parse_error: "⚠️ Could not parse `/resume` arguments: {error}. +Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`." + matrix_no_named_sessions: "No named sessions found for this Matrix room. +Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room ` to explicitly cross room boundaries." + matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries." + matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here." + matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**. +Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}" no_named_sessions: "Não foram encontradas sessões com nome.\nUsa `/title A minha sessão` para nomear a sessão atual e depois `/resume A minha sessão` para voltar a ela." list_header: "📋 **Sessões com nome**\n" list_item: "• **{title}**{preview_part}" @@ -251,6 +259,12 @@ gateway: status: header: "📊 **Estado do Hermes Gateway**" + matrix_scope_header: "**Matrix scope:**" + matrix_scope_room: " room: {room}" + matrix_scope_room_id: " room_id: {room_id}" + matrix_scope_thread: " thread_id: {thread_id}" + matrix_scope_mode: " session_scope: {scope}" + matrix_scope_key: " session_key: {session_key}" session_id: "**ID da sessão:** `{session_id}`" title: "**Título:** {title}" created: "**Criada:** {timestamp}" diff --git a/locales/ru.yaml b/locales/ru.yaml index ce526d7b47f..979601aedaa 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -219,6 +219,14 @@ gateway: resume: db_unavailable: "База данных сеансов недоступна." + parse_error: "⚠️ Could not parse `/resume` arguments: {error}. +Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`." + matrix_no_named_sessions: "No named sessions found for this Matrix room. +Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room ` to explicitly cross room boundaries." + matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries." + matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here." + matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**. +Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}" no_named_sessions: "Именованных сеансов не найдено.\nИспользуйте `/title Мой сеанс`, чтобы назвать текущий сеанс, затем `/resume Мой сеанс`, чтобы вернуться к нему позже." list_header: "📋 **Именованные сеансы**\n" list_item: "• **{title}**{preview_part}" @@ -251,6 +259,12 @@ gateway: status: header: "📊 **Состояние Hermes Gateway**" + matrix_scope_header: "**Matrix scope:**" + matrix_scope_room: " room: {room}" + matrix_scope_room_id: " room_id: {room_id}" + matrix_scope_thread: " thread_id: {thread_id}" + matrix_scope_mode: " session_scope: {scope}" + matrix_scope_key: " session_key: {session_key}" session_id: "**ID сеанса:** `{session_id}`" title: "**Название:** {title}" created: "**Создано:** {timestamp}" diff --git a/locales/tr.yaml b/locales/tr.yaml index ecd23d8e977..259e56fa273 100644 --- a/locales/tr.yaml +++ b/locales/tr.yaml @@ -219,6 +219,14 @@ gateway: resume: db_unavailable: "Oturum veritabanı kullanılamıyor." + parse_error: "⚠️ Could not parse `/resume` arguments: {error}. +Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`." + matrix_no_named_sessions: "No named sessions found for this Matrix room. +Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room ` to explicitly cross room boundaries." + matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries." + matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here." + matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**. +Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}" no_named_sessions: "Adlandırılmış oturum bulunamadı.\nMevcut oturumu adlandırmak için `/title Oturumum`, daha sonra geri dönmek için `/resume Oturumum` kullanın." list_header: "📋 **Adlandırılmış Oturumlar**\n" list_item: "• **{title}**{preview_part}" @@ -251,6 +259,12 @@ gateway: status: header: "📊 **Hermes Gateway Durumu**" + matrix_scope_header: "**Matrix scope:**" + matrix_scope_room: " room: {room}" + matrix_scope_room_id: " room_id: {room_id}" + matrix_scope_thread: " thread_id: {thread_id}" + matrix_scope_mode: " session_scope: {scope}" + matrix_scope_key: " session_key: {session_key}" session_id: "**Oturum kimliği:** `{session_id}`" title: "**Başlık:** {title}" created: "**Oluşturuldu:** {timestamp}" diff --git a/locales/uk.yaml b/locales/uk.yaml index b564ec30545..8f7d10ebfb7 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -219,6 +219,14 @@ gateway: resume: db_unavailable: "База даних сеансів недоступна." + parse_error: "⚠️ Could not parse `/resume` arguments: {error}. +Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`." + matrix_no_named_sessions: "No named sessions found for this Matrix room. +Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room ` to explicitly cross room boundaries." + matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries." + matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here." + matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**. +Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}" no_named_sessions: "Іменованих сеансів не знайдено.\nВикористайте `/title Мій сеанс`, щоб назвати поточний сеанс, потім `/resume Мій сеанс`, щоб повернутися до нього." list_header: "📋 **Іменовані сеанси**\n" list_item: "• **{title}**{preview_part}" @@ -251,6 +259,12 @@ gateway: status: header: "📊 **Стан Hermes Gateway**" + matrix_scope_header: "**Matrix scope:**" + matrix_scope_room: " room: {room}" + matrix_scope_room_id: " room_id: {room_id}" + matrix_scope_thread: " thread_id: {thread_id}" + matrix_scope_mode: " session_scope: {scope}" + matrix_scope_key: " session_key: {session_key}" session_id: "**ID сесії:** `{session_id}`" title: "**Назва:** {title}" created: "**Створено:** {timestamp}" diff --git a/locales/zh-hant.yaml b/locales/zh-hant.yaml index a2210d2c225..982a9b2918b 100644 --- a/locales/zh-hant.yaml +++ b/locales/zh-hant.yaml @@ -219,6 +219,14 @@ gateway: resume: db_unavailable: "工作階段資料庫無法使用。" + parse_error: "⚠️ Could not parse `/resume` arguments: {error}. +Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`." + matrix_no_named_sessions: "No named sessions found for this Matrix room. +Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room ` to explicitly cross room boundaries." + matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries." + matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here." + matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**. +Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}" no_named_sessions: "找不到已命名的工作階段。\n使用 `/title 我的工作階段` 為目前工作階段命名,然後使用 `/resume 我的工作階段` 返回。" list_header: "📋 **已命名工作階段**\n" list_item: "• **{title}**{preview_part}" @@ -251,6 +259,12 @@ gateway: status: header: "📊 **Hermes 閘道狀態**" + matrix_scope_header: "**Matrix scope:**" + matrix_scope_room: " room: {room}" + matrix_scope_room_id: " room_id: {room_id}" + matrix_scope_thread: " thread_id: {thread_id}" + matrix_scope_mode: " session_scope: {scope}" + matrix_scope_key: " session_key: {session_key}" session_id: "**工作階段 ID:** `{session_id}`" title: "**標題:** {title}" created: "**建立時間:** {timestamp}" diff --git a/locales/zh.yaml b/locales/zh.yaml index 896a958778f..ee20289e16d 100644 --- a/locales/zh.yaml +++ b/locales/zh.yaml @@ -219,6 +219,14 @@ gateway: resume: db_unavailable: "会话数据库不可用。" + parse_error: "⚠️ Could not parse `/resume` arguments: {error}. +Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`." + matrix_no_named_sessions: "No named sessions found for this Matrix room. +Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room ` to explicitly cross room boundaries." + matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries." + matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here." + matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**. +Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}" no_named_sessions: "未找到已命名的会话。\n使用 `/title 我的会话` 为当前会话命名,然后用 `/resume 我的会话` 返回。" list_header: "📋 **已命名会话**\n" list_item: "• **{title}**{preview_part}" @@ -251,6 +259,12 @@ gateway: status: header: "📊 **Hermes 网关状态**" + matrix_scope_header: "**Matrix scope:**" + matrix_scope_room: " room: {room}" + matrix_scope_room_id: " room_id: {room_id}" + matrix_scope_thread: " thread_id: {thread_id}" + matrix_scope_mode: " session_scope: {scope}" + matrix_scope_key: " session_key: {session_key}" session_id: "**会话 ID:** `{session_id}`" title: "**标题:** {title}" created: "**创建时间:** {timestamp}" diff --git a/tests/gateway/test_matrix.py b/tests/gateway/test_matrix.py index 00b100d5a89..34388817655 100644 --- a/tests/gateway/test_matrix.py +++ b/tests/gateway/test_matrix.py @@ -1,6 +1,9 @@ """Tests for Matrix platform adapter (mautrix-python backend).""" import asyncio +import re +import stat import sys +import time import types import pytest from unittest.mock import MagicMock, patch, AsyncMock @@ -138,7 +141,14 @@ def _make_fake_mautrix(): return {} class MemorySyncStore: - pass + def __init__(self): + self.next_batch = None + + async def get_next_batch(self): + return self.next_batch + + async def put_next_batch(self, token): + self.next_batch = token mautrix_client_state_store.MemoryStateStore = MemoryStateStore mautrix_client_state_store.MemorySyncStore = MemorySyncStore @@ -295,6 +305,20 @@ class TestMatrixConfigLoading: mc = config.platforms[Platform.MATRIX] assert mc.extra.get("encryption") is True + def test_matrix_e2ee_mode_optional_sets_config(self, monkeypatch): + monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_abc123") + monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org") + monkeypatch.setenv("MATRIX_E2EE_MODE", "optional") + monkeypatch.delenv("MATRIX_ENCRYPTION", raising=False) + + from gateway.config import GatewayConfig, _apply_env_overrides + config = GatewayConfig() + _apply_env_overrides(config) + + mc = config.platforms[Platform.MATRIX] + assert mc.extra.get("encryption") is True + assert mc.extra.get("e2ee_mode") == "optional" + def test_matrix_encryption_default_off(self, monkeypatch): monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_abc123") monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org") @@ -465,6 +489,102 @@ class TestMatrixDmDetection: assert self.adapter._dm_rooms["!room_b:ex.org"] is True assert self.adapter._dm_rooms["!room_c:ex.org"] is False + @pytest.mark.asyncio + async def test_m_direct_room_is_dm(self): + """m.direct account data is the authoritative DM signal.""" + self.adapter._joined_rooms = {"!dm_room:ex.org"} + self.adapter._dm_rooms = {"!dm_room:ex.org": True} + self.adapter._client = MagicMock() + self.adapter._client.get_state_event = AsyncMock(side_effect=Exception("no state")) + self.adapter._client.state_store = MagicMock() + self.adapter._client.state_store.get_members = AsyncMock(return_value=["@bot:ex.org", "@alice:ex.org"]) + + assert await self.adapter._is_dm_room("!dm_room:ex.org") is True + + @pytest.mark.asyncio + async def test_named_two_member_room_is_not_dm(self): + """A named two-member room must remain a room, not a DM.""" + self.adapter._joined_rooms = {"!project:ex.org"} + self.adapter._dm_rooms = {} + self.adapter._client = MagicMock() + self.adapter._client.get_state_event = AsyncMock( + side_effect=lambda room_id, event_type: {"name": "Project Room"} + if event_type == "m.room.name" + else (_ for _ in ()).throw(Exception("no alias")) + ) + self.adapter._client.state_store = MagicMock() + self.adapter._client.state_store.get_members = AsyncMock( + return_value=["@bot:ex.org", "@alice:ex.org"] + ) + + identity = await self.adapter._resolve_room_identity("!project:ex.org") + + assert identity.chat_type == "room" + assert identity.display_name == "Project Room" + assert identity.joined_member_count == 2 + assert await self.adapter._is_dm_room("!project:ex.org") is False + + @pytest.mark.asyncio + async def test_named_room_overrides_stale_dm_cache(self): + """Explicit room names should defeat stale/conflicting m.direct data.""" + self.adapter._joined_rooms = {"!stale:ex.org"} + self.adapter._dm_rooms = {"!stale:ex.org": True} + self.adapter._client = MagicMock() + self.adapter._client.get_state_event = AsyncMock( + side_effect=lambda room_id, event_type: {"content": {"name": "Ops Room"}} + if event_type == "m.room.name" + else (_ for _ in ()).throw(Exception("no alias")) + ) + self.adapter._client.state_store = MagicMock() + self.adapter._client.state_store.get_members = AsyncMock(return_value=["@bot:ex.org", "@alice:ex.org"]) + + identity = await self.adapter._resolve_room_identity("!stale:ex.org") + + assert identity.chat_type == "room" + assert identity.conflict is True + assert await self.adapter._is_dm_room("!stale:ex.org") is False + + @pytest.mark.asyncio + async def test_canonical_alias_used_when_name_missing(self): + self.adapter._joined_rooms = {"!alias:ex.org"} + self.adapter._dm_rooms = {} + self.adapter._client = MagicMock() + + async def get_state_event(room_id, event_type): + if event_type == "m.room.name": + raise Exception("no name") + if event_type == "m.room.canonical_alias": + return {"content": {"alias": "#hermes:ex.org"}} + raise Exception("unknown") + + self.adapter._client.get_state_event = AsyncMock(side_effect=get_state_event) + self.adapter._client.state_store = MagicMock() + self.adapter._client.state_store.get_members = AsyncMock(return_value=None) + + identity = await self.adapter._resolve_room_identity("!alias:ex.org") + + assert identity.display_name == "#hermes:ex.org" + assert identity.chat_type == "room" + + @pytest.mark.asyncio + async def test_non_string_m_direct_entries_ignored(self): + self.adapter._joined_rooms = {"!room_a:ex.org", "!room_b:ex.org"} + + mock_client = MagicMock() + mock_resp = MagicMock() + mock_resp.content = { + "@alice:ex.org": ["!room_a:ex.org", 42, None], + } + mock_client.get_account_data = AsyncMock(return_value=mock_resp) + self.adapter._client = mock_client + + await self.adapter._refresh_dm_cache() + + assert self.adapter._dm_rooms == { + "!room_a:ex.org": True, + "!room_b:ex.org": False, + } + # --------------------------------------------------------------------------- # Reply fallback stripping @@ -805,6 +925,101 @@ class TestMatrixFormatMessage: assert "http://b.com/2.png" in result +# --------------------------------------------------------------------------- +# Rendering payloads +# --------------------------------------------------------------------------- + +class TestMatrixRenderingPayloads: + def setup_method(self): + self.adapter = _make_adapter() + self.mock_client = MagicMock() + self.mock_client.send_message_event = AsyncMock(return_value="$evt") + self.adapter._client = self.mock_client + + def _sent_contents(self): + return [ + call.args[2] if len(call.args) > 2 else call.kwargs["content"] + for call in self.mock_client.send_message_event.await_args_list + ] + + @pytest.mark.asyncio + async def test_render_plain_and_html_body(self): + result = await self.adapter.send("!room:example.org", "**Bold** and plain") + + assert result.success is True + content = self._sent_contents()[0] + assert content["body"] == "**Bold** and plain" + assert content["format"] == "org.matrix.custom.html" + assert "Bold" in content["formatted_body"] + + @pytest.mark.asyncio + async def test_thread_payload_uses_m_thread_with_reply_fallback(self): + result = await self.adapter.send( + "!room:example.org", + "threaded", + metadata={"thread_id": "$root"}, + ) + + assert result.success is True + relates_to = self._sent_contents()[0]["m.relates_to"] + assert relates_to == { + "rel_type": "m.thread", + "event_id": "$root", + "is_falling_back": True, + "m.in_reply_to": {"event_id": "$root"}, + } + + @pytest.mark.asyncio + async def test_thread_payload_preserves_explicit_reply_target(self): + result = await self.adapter.send( + "!room:example.org", + "threaded reply", + reply_to="$reply", + metadata={"thread_id": "$root"}, + ) + + assert result.success is True + relates_to = self._sent_contents()[0]["m.relates_to"] + assert relates_to["event_id"] == "$root" + assert relates_to["m.in_reply_to"] == {"event_id": "$reply"} + + @pytest.mark.asyncio + async def test_edit_payload_uses_m_replace(self): + result = await self.adapter.edit_message( + "!room:example.org", + "$original", + "edited **body**", + ) + + assert result.success is True + content = self._sent_contents()[0] + assert content["m.relates_to"] == { + "rel_type": "m.replace", + "event_id": "$original", + } + assert content["m.new_content"]["body"] == "edited **body**" + assert content["body"] == "* edited **body**" + + @pytest.mark.asyncio + async def test_long_response_split_preserves_thread_context(self): + long_text = "Intro\n```python\n" + ("print('hello')\n" * 500) + "```\nDone" + + result = await self.adapter.send( + "!room:example.org", + long_text, + metadata={"thread_id": "$root"}, + ) + + assert result.success is True + contents = self._sent_contents() + assert len(contents) > 1 + for content in contents: + assert content["m.relates_to"]["rel_type"] == "m.thread" + assert content["m.relates_to"]["event_id"] == "$root" + assert content["m.relates_to"]["m.in_reply_to"] == {"event_id": "$root"} + assert content["body"].count("```") % 2 == 0 + + # --------------------------------------------------------------------------- # Markdown to HTML conversion # --------------------------------------------------------------------------- @@ -834,6 +1049,47 @@ class TestMatrixMarkdownToHtml: result = self.adapter._markdown_to_html("Hello world") assert "Hello world" in result + def test_matrix_markdown_strips_script_tag(self): + result = self.adapter._markdown_to_html("Hello ") + assert "bold') + assert "onclick" not in result.lower() + assert "bold" in result + + def test_matrix_markdown_rejects_javascript_links(self): + result = self.adapter._markdown_to_html("[click](javascript:alert(1))") + assert "javascript:" not in result.lower() + assert "click') + assert "javascript:" not in result.lower() + assert "href=" not in result.lower() + assert "click" in result + + def test_matrix_markdown_preserves_code_fences(self): + result = self.adapter._markdown_to_html("```python\nprint('x')\n```") + assert "
" in result
+        assert "= 0
+        assert diagnostics["e2ee"]["recovery_key_configured"] is True
+        assert diagnostics["media"]["max_media_bytes"] == 123
+
+    def test_matrix_recovery_key_is_never_logged(self, caplog, monkeypatch):
+        from gateway.platforms.matrix import _handle_generated_matrix_recovery_key
+
+        secret = "super-secret-generated-recovery-key"
+        monkeypatch.delenv("MATRIX_RECOVERY_KEY_OUTPUT_FILE", raising=False)
+
+        _handle_generated_matrix_recovery_key("@bot:example.org", secret)
+
+        assert secret not in caplog.text
+        assert "will not be logged" in caplog.text
+
+    def test_matrix_recovery_key_output_file_is_0600(self, tmp_path, monkeypatch, caplog):
+        from gateway.platforms.matrix import _handle_generated_matrix_recovery_key
+
+        secret = "super-secret-generated-recovery-key"
+        output_path = tmp_path / "matrix-recovery-key.txt"
+        monkeypatch.setenv("MATRIX_RECOVERY_KEY_OUTPUT_FILE", str(output_path))
+
+        _handle_generated_matrix_recovery_key("@bot:example.org", secret)
+
+        assert output_path.read_text().strip() == secret
+        assert stat.S_IMODE(output_path.stat().st_mode) == 0o600
+        assert secret not in caplog.text
+
+    @pytest.mark.asyncio
+    async def test_matrix_recovery_key_bootstrap_skips_without_output_file(
+        self,
+        monkeypatch,
+        caplog,
+    ):
+        from gateway.platforms.matrix import MatrixAdapter
+
+        monkeypatch.delenv("MATRIX_RECOVERY_KEY", raising=False)
+        monkeypatch.delenv("MATRIX_RECOVERY_KEY_OUTPUT_FILE", raising=False)
+        config = PlatformConfig(
+            enabled=True,
+            token="syt_test_token",
+            extra={
+                "homeserver": "https://matrix.example.org",
+                "user_id": "@bot:example.org",
+                "encryption": True,
+            },
+        )
+        adapter = MatrixAdapter(config)
+        fake_mautrix_mods = _make_fake_mautrix()
+
+        mock_client = MagicMock()
+        mock_client.mxid = "@bot:example.org"
+        mock_client.device_id = None
+        mock_client.state_store = MagicMock()
+        mock_client.sync_store = MagicMock()
+        mock_client.crypto = None
+        mock_client.whoami = AsyncMock(return_value=MagicMock(user_id="@bot:example.org", device_id="DEV123"))
+        mock_client.sync = AsyncMock(return_value={"rooms": {"join": {}}})
+        mock_client.add_event_handler = MagicMock()
+        mock_client.add_dispatcher = MagicMock()
+        mock_client.handle_sync = MagicMock(return_value=[])
+        mock_client.query_keys = AsyncMock(return_value={
+            "device_keys": {"@bot:example.org": {"DEV123": {
+                "keys": {"ed25519:DEV123": "fake_ed25519_key"},
+            }}},
+        })
+        mock_client.api = MagicMock()
+        mock_client.api.token = "syt_test_token"
+        mock_client.api.session = MagicMock()
+        mock_client.api.session.close = AsyncMock()
+
+        mock_olm = MagicMock()
+        mock_olm.load = AsyncMock()
+        mock_olm.share_keys = AsyncMock()
+        mock_olm.get_own_cross_signing_public_keys = AsyncMock(return_value=None)
+        mock_olm.generate_recovery_key = AsyncMock(return_value="super-secret-key")
+        mock_olm.share_keys_min_trust = None
+        mock_olm.send_keys_min_trust = None
+        mock_olm.account = MagicMock()
+        mock_olm.account.identity_keys = {"ed25519": "fake_ed25519_key"}
+
+        fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
+        fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(return_value=mock_olm)
+
+        from gateway.platforms import matrix as matrix_mod
+        with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True):
+            with patch.dict("sys.modules", fake_mautrix_mods):
+                with patch.object(adapter, "_refresh_dm_cache", AsyncMock()):
+                    with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)):
+                        assert await adapter.connect() is True
+
+        mock_olm.generate_recovery_key.assert_not_called()
+        assert "MATRIX_RECOVERY_KEY_OUTPUT_FILE is not configured" in caplog.text
+        assert "super-secret-key" not in caplog.text
+        await adapter.disconnect()
+
+    @pytest.mark.asyncio
+    async def test_matrix_recovery_key_bootstrap_skips_existing_output_file(
+        self,
+        tmp_path,
+        monkeypatch,
+        caplog,
+    ):
+        from gateway.platforms.matrix import MatrixAdapter
+
+        output_path = tmp_path / "matrix-recovery-key.txt"
+        output_path.write_text("existing\n")
+        monkeypatch.delenv("MATRIX_RECOVERY_KEY", raising=False)
+        monkeypatch.setenv("MATRIX_RECOVERY_KEY_OUTPUT_FILE", str(output_path))
+        config = PlatformConfig(
+            enabled=True,
+            token="syt_test_token",
+            extra={
+                "homeserver": "https://matrix.example.org",
+                "user_id": "@bot:example.org",
+                "encryption": True,
+            },
+        )
+        adapter = MatrixAdapter(config)
+        fake_mautrix_mods = _make_fake_mautrix()
+
+        mock_client = MagicMock()
+        mock_client.mxid = "@bot:example.org"
+        mock_client.device_id = None
+        mock_client.state_store = MagicMock()
+        mock_client.sync_store = MagicMock()
+        mock_client.crypto = None
+        mock_client.whoami = AsyncMock(return_value=MagicMock(user_id="@bot:example.org", device_id="DEV123"))
+        mock_client.sync = AsyncMock(return_value={"rooms": {"join": {}}})
+        mock_client.add_event_handler = MagicMock()
+        mock_client.add_dispatcher = MagicMock()
+        mock_client.handle_sync = MagicMock(return_value=[])
+        mock_client.query_keys = AsyncMock(return_value={
+            "device_keys": {"@bot:example.org": {"DEV123": {
+                "keys": {"ed25519:DEV123": "fake_ed25519_key"},
+            }}},
+        })
+        mock_client.api = MagicMock()
+        mock_client.api.token = "syt_test_token"
+        mock_client.api.session = MagicMock()
+        mock_client.api.session.close = AsyncMock()
+
+        mock_olm = MagicMock()
+        mock_olm.load = AsyncMock()
+        mock_olm.share_keys = AsyncMock()
+        mock_olm.get_own_cross_signing_public_keys = AsyncMock(return_value=None)
+        mock_olm.generate_recovery_key = AsyncMock(return_value="super-secret-key")
+        mock_olm.share_keys_min_trust = None
+        mock_olm.send_keys_min_trust = None
+        mock_olm.account = MagicMock()
+        mock_olm.account.identity_keys = {"ed25519": "fake_ed25519_key"}
+
+        fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
+        fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(return_value=mock_olm)
+
+        from gateway.platforms import matrix as matrix_mod
+        with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True):
+            with patch.dict("sys.modules", fake_mautrix_mods):
+                with patch.object(adapter, "_refresh_dm_cache", AsyncMock()):
+                    with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)):
+                        assert await adapter.connect() is True
+
+        mock_olm.generate_recovery_key.assert_not_called()
+        assert "already exists" in caplog.text
+        assert "super-secret-key" not in caplog.text
+        assert output_path.read_text() == "existing\n"
+        await adapter.disconnect()
+
+    def test_matrix_diagnostics_redacts_recovery_key(self, monkeypatch):
+        monkeypatch.setenv("MATRIX_RECOVERY_KEY", "diagnostic-secret-recovery-key")
+        adapter = _make_adapter()
+
+        diagnostics = adapter.get_diagnostics()
+
+        assert diagnostics["e2ee"]["recovery_key_configured"] is True
+        assert "diagnostic-secret-recovery-key" not in str(diagnostics)
+
+    def test_capability_matrix_is_declared_for_docs(self):
+        from gateway.platforms.matrix import get_matrix_capabilities
+
+        capabilities = get_matrix_capabilities()
+
+        assert capabilities == {
+            "text": "yes",
+            "threads": "yes",
+            "reactions": "yes",
+            "approvals": "yes",
+            "model picker": "yes",
+            "thinking panes": "yes",
+            "images": "yes",
+            "multiple images": "yes",
+            "files": "yes",
+            "voice/audio": "yes",
+            "video": "yes",
+            "E2EE": "off / optional / required",
+            "diagnostics": "yes",
+        }
+
+    def test_matrix_capability_claims_match_adapter_surfaces(self):
+        from gateway.platforms.matrix import MatrixAdapter, get_matrix_capabilities
+
+        capabilities = get_matrix_capabilities()
+        required_methods = {
+            "text": "send",
+            "threads": "_apply_relation_metadata",
+            "reactions": "_send_reaction",
+            "approvals": "send_exec_approval",
+            "model picker": "send_model_picker",
+            "thinking panes": "edit_message",
+            "images": "send_image",
+            "multiple images": "send_multiple_images",
+            "files": "send_document",
+            "voice/audio": "send_voice",
+            "video": "send_video",
+            "diagnostics": "get_diagnostics",
+        }
+
+        for capability, method in required_methods.items():
+            assert capabilities[capability] == "yes"
+            assert hasattr(MatrixAdapter, method), f"{capability} needs {method}"
+        assert capabilities["E2EE"] == "off / optional / required"
+
+    def test_matrix_docs_capability_table_matches_declaration(self):
+        from pathlib import Path
+
+        from gateway.platforms.matrix import get_matrix_capabilities
+
+        docs = (
+            Path(__file__).resolve().parents[2]
+            / "website"
+            / "docs"
+            / "user-guide"
+            / "messaging"
+            / "matrix.md"
+        ).read_text()
+
+        for capability, status in get_matrix_capabilities().items():
+            assert f"| {capability} | {status} |" in docs
+
 
 class TestMatrixEncryptedSendFallback:
     @pytest.mark.asyncio
@@ -2282,6 +3188,354 @@ class TestMatrixImageOnlyMediaNormalization:
 
         assert captured_event is not None
         assert captured_event.text == "Please describe this chart"
+
+    @pytest.mark.asyncio
+    async def test_inbound_oversized_media_is_rejected(self):
+        captured_event = None
+
+        async def capture(msg_event):
+            nonlocal captured_event
+            captured_event = msg_event
+
+        self.adapter._max_media_bytes = 10
+        self.adapter.handle_message = capture
+
+        await self.adapter._handle_media_message(
+            room_id="!room:example.org",
+            sender="@alice:example.org",
+            event_id="$image-big",
+            event_ts=0.0,
+            source_content={
+                "msgtype": "m.image",
+                "body": "huge.png",
+                "url": "mxc://example/huge.png",
+                "info": {"mimetype": "image/png", "size": 11},
+            },
+            relates_to={},
+            msgtype="m.image",
+        )
+
+        assert captured_event is None
+        self.adapter._client.download_media.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_external_media_download_rejects_oversized_content_length(self, monkeypatch):
+        import aiohttp
+
+        class _Content:
+            async def iter_chunked(self, _size):
+                yield b"x"
+
+        class _Response:
+            url = "https://example.com/image.png"
+            headers = {"Content-Length": "11"}
+            content_type = "image/png"
+            content = _Content()
+
+            async def __aenter__(self):
+                return self
+
+            async def __aexit__(self, *_args):
+                return None
+
+            def raise_for_status(self):
+                return None
+
+        class _Session:
+            async def __aenter__(self):
+                return self
+
+            async def __aexit__(self, *_args):
+                return None
+
+            def get(self, *_args, **_kwargs):
+                return _Response()
+
+        self.adapter._max_media_bytes = 10
+        monkeypatch.setattr(aiohttp, "ClientSession", lambda **_kwargs: _Session())
+
+        with pytest.raises(ValueError, match="exceeds Matrix limit"):
+            await self.adapter._download_external_media_with_cap(
+                "https://example.com/image.png"
+            )
+
+    @pytest.mark.asyncio
+    async def test_external_media_download_rejects_oversized_stream(self, monkeypatch):
+        import aiohttp
+
+        class _Content:
+            async def iter_chunked(self, _size):
+                yield b"12345"
+                yield b"67890"
+                yield b"!"
+
+        class _Response:
+            url = "https://example.com/image.png"
+            headers = {}
+            content_type = "image/png"
+            content = _Content()
+
+            async def __aenter__(self):
+                return self
+
+            async def __aexit__(self, *_args):
+                return None
+
+            def raise_for_status(self):
+                return None
+
+        class _Session:
+            async def __aenter__(self):
+                return self
+
+            async def __aexit__(self, *_args):
+                return None
+
+            def get(self, *_args, **_kwargs):
+                return _Response()
+
+        self.adapter._max_media_bytes = 10
+        monkeypatch.setattr(aiohttp, "ClientSession", lambda **_kwargs: _Session())
+
+        with pytest.raises(ValueError, match="exceeds Matrix limit"):
+            await self.adapter._download_external_media_with_cap(
+                "https://example.com/image.png"
+            )
+
+    @pytest.mark.asyncio
+    async def test_external_media_download_rejects_unsafe_redirect(self, monkeypatch):
+        import aiohttp
+
+        class _Content:
+            async def iter_chunked(self, _size):
+                yield b"ok"
+
+        class _Response:
+            url = "http://127.0.0.1/private.png"
+            headers = {}
+            content_type = "image/png"
+            content = _Content()
+
+            async def __aenter__(self):
+                return self
+
+            async def __aexit__(self, *_args):
+                return None
+
+            def raise_for_status(self):
+                return None
+
+        class _Session:
+            async def __aenter__(self):
+                return self
+
+            async def __aexit__(self, *_args):
+                return None
+
+            def get(self, *_args, **_kwargs):
+                return _Response()
+
+        monkeypatch.setattr(aiohttp, "ClientSession", lambda **_kwargs: _Session())
+
+        with pytest.raises(ValueError, match="unsafe redirect"):
+            await self.adapter._download_external_media_with_cap(
+                "https://example.com/image.png"
+            )
+
+    @pytest.mark.asyncio
+    async def test_external_media_download_rejects_unsafe_initial_url(self):
+        with pytest.raises(ValueError, match="unsafe media URL"):
+            await self.adapter._download_external_media_with_cap(
+                "file:///etc/passwd"
+            )
+
+    @pytest.mark.asyncio
+    async def test_external_media_download_rejects_non_image_content_type(self, monkeypatch):
+        import aiohttp
+
+        class _Content:
+            async def iter_chunked(self, _size):
+                yield b""
+
+        class _Response:
+            url = "https://example.com/image.png"
+            headers = {}
+            content_type = "text/html"
+            content = _Content()
+
+            async def __aenter__(self):
+                return self
+
+            async def __aexit__(self, *_args):
+                return None
+
+            def raise_for_status(self):
+                return None
+
+        class _Session:
+            async def __aenter__(self):
+                return self
+
+            async def __aexit__(self, *_args):
+                return None
+
+            def get(self, *_args, **_kwargs):
+                return _Response()
+
+        monkeypatch.setattr(aiohttp, "ClientSession", lambda **_kwargs: _Session())
+
+        with pytest.raises(ValueError, match="not an image"):
+            await self.adapter._download_external_media_with_cap(
+                "https://example.com/image.png"
+            )
+
+    @pytest.mark.asyncio
+    async def test_send_image_failure_log_redacts_signed_url(self, caplog):
+        from gateway.platforms.base import SendResult
+
+        signed_url = "https://example.com/image.png?signature=secret-token#frag"
+        self.adapter._download_external_media_with_cap = AsyncMock(
+            side_effect=ValueError("download failed")
+        )
+        self.adapter.send = AsyncMock(return_value=SendResult(success=True))
+
+        await self.adapter.send_image("!room:example.org", signed_url)
+
+        assert "https://example.com/image.png" in caplog.text
+        assert "secret-token" not in caplog.text
+        assert "#frag" not in caplog.text
+
+    @pytest.mark.asyncio
+    async def test_send_image_failure_response_does_not_expose_signed_url_query(self):
+        from gateway.platforms.base import SendResult
+
+        signed_url = "https://example.com/image.png?signature=secret-token"
+        self.adapter._download_external_media_with_cap = AsyncMock(
+            side_effect=ValueError("download failed")
+        )
+        self.adapter.send = AsyncMock(return_value=SendResult(success=True))
+
+        await self.adapter.send_image("!room:example.org", signed_url)
+
+        sent_text = self.adapter.send.await_args.args[1]
+        assert "signature=" not in sent_text
+        assert "secret-token" not in sent_text
+        assert signed_url not in sent_text
+        assert "source URL was not shown" in sent_text
+
+    @pytest.mark.asyncio
+    async def test_send_image_failure_response_does_not_expose_signed_url_fragment(self):
+        from gateway.platforms.base import SendResult
+
+        signed_url = "https://example.com/image.png#fragment-secret"
+        self.adapter._download_external_media_with_cap = AsyncMock(
+            side_effect=ValueError("download failed")
+        )
+        self.adapter.send = AsyncMock(return_value=SendResult(success=True))
+
+        await self.adapter.send_image("!room:example.org", signed_url)
+
+        sent_text = self.adapter.send.await_args.args[1]
+        assert "#fragment-secret" not in sent_text
+        assert "fragment-secret" not in sent_text
+        assert signed_url not in sent_text
+        assert "source URL was not shown" in sent_text
+
+    @pytest.mark.asyncio
+    async def test_send_image_failure_response_preserves_caption(self):
+        from gateway.platforms.base import SendResult
+
+        signed_url = "https://example.com/image.png?signature=secret-token#fragment"
+        self.adapter._download_external_media_with_cap = AsyncMock(
+            side_effect=ValueError("download failed")
+        )
+        self.adapter.send = AsyncMock(return_value=SendResult(success=True))
+
+        await self.adapter.send_image(
+            "!room:example.org",
+            signed_url,
+            caption="Here is the image",
+        )
+
+        sent_text = self.adapter.send.await_args.args[1]
+        assert "Here is the image" in sent_text
+        assert "signature=" not in sent_text
+        assert "secret-token" not in sent_text
+        assert "#fragment" not in sent_text
+        assert signed_url not in sent_text
+
+    @pytest.mark.asyncio
+    async def test_send_image_failure_log_still_redacts_signed_url(self, caplog):
+        from gateway.platforms.base import SendResult
+
+        signed_url = "https://example.com/image.png?signature=secret-token#fragment"
+        self.adapter._download_external_media_with_cap = AsyncMock(
+            side_effect=ValueError("download failed")
+        )
+        self.adapter.send = AsyncMock(return_value=SendResult(success=True))
+
+        await self.adapter.send_image("!room:example.org", signed_url)
+
+        assert "https://example.com/image.png" in caplog.text
+        assert "signature=" not in caplog.text
+        assert "secret-token" not in caplog.text
+        assert "#fragment" not in caplog.text
+
+    @pytest.mark.asyncio
+    async def test_inbound_non_mxc_media_url_is_rejected(self):
+        captured_event = None
+
+        async def capture(msg_event):
+            nonlocal captured_event
+            captured_event = msg_event
+
+        self.adapter.handle_message = capture
+
+        await self.adapter._handle_media_message(
+            room_id="!room:example.org",
+            sender="@alice:example.org",
+            event_id="$image-http",
+            event_ts=0.0,
+            source_content={
+                "msgtype": "m.image",
+                "body": "remote.png",
+                "url": "https://evil.example.org/remote.png",
+                "info": {"mimetype": "image/png", "size": 1},
+            },
+            relates_to={},
+            msgtype="m.image",
+        )
+
+        assert captured_event is None
+        self.adapter._client.download_media.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_inbound_encrypted_non_mxc_media_url_is_rejected(self):
+        captured_event = None
+
+        async def capture(msg_event):
+            nonlocal captured_event
+            captured_event = msg_event
+
+        self.adapter.handle_message = capture
+
+        await self.adapter._handle_media_message(
+            room_id="!room:example.org",
+            sender="@alice:example.org",
+            event_id="$image-enc-http",
+            event_ts=0.0,
+            source_content={
+                "msgtype": "m.image",
+                "body": "remote.png",
+                "file": {"url": "https://evil.example.org/remote.png"},
+                "info": {"mimetype": "image/png", "size": 1},
+            },
+            relates_to={},
+            msgtype="m.image",
+        )
+
+        assert captured_event is None
+        self.adapter._client.download_media.assert_not_called()
 # ---------------------------------------------------------------------------
 # Message redaction
 # ---------------------------------------------------------------------------
@@ -2471,11 +3725,11 @@ class TestMatrixOnRoomMessageFilter:
         self.adapter._handle_media_message = AsyncMock()
 
     @staticmethod
-    def _mk_event(sender, body="hi", msgtype="m.text", event_id=None, ts=None):
+    def _mk_event(sender, body="hi", msgtype="m.text", event_id=None, ts=None, room_id=None):
         import time as _t
 
         ev = MagicMock()
-        ev.room_id = "!room:example.org"
+        ev.room_id = room_id or "!room:example.org"
         ev.sender = sender
         ev.event_id = event_id or f"$evt-{sender}-{body}"
         ev.timestamp = int((ts or _t.time()) * 1000)
@@ -2520,6 +3774,234 @@ class TestMatrixOnRoomMessageFilter:
         await self.adapter._on_room_message(ev)
         self.adapter._handle_text_message.assert_awaited_once()
 
+    @pytest.mark.asyncio
+    async def test_unauthorized_user_reaches_text_handler(self):
+        """MATRIX_ALLOWED_USERS is enforced by gateway authz, not adapter intake."""
+        self.adapter._allowed_user_ids = {"@alice:example.org"}
+        ev = self._mk_event(sender="@mallory:example.org", body="hello bot")
+        await self.adapter._on_room_message(ev)
+        self.adapter._handle_text_message.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_authorized_user_reaches_text_handler(self):
+        self.adapter._allowed_user_ids = {"@alice:example.org"}
+        ev = self._mk_event(sender="@alice:example.org", body="hello bot")
+        await self.adapter._on_room_message(ev)
+        self.adapter._handle_text_message.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_unauthorized_room_is_dropped(self):
+        self.adapter._allowed_room_ids = {"!allowed:example.org"}
+        self.adapter._is_dm_room = AsyncMock(return_value=False)
+        ev = self._mk_event(
+            sender="@alice:example.org",
+            body="hello bot",
+            room_id="!other:example.org",
+        )
+        await self.adapter._on_room_message(ev)
+        self.adapter._handle_text_message.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_dm_room_bypasses_allowed_room_gate(self):
+        self.adapter._allowed_room_ids = {"!project:example.org"}
+        self.adapter._is_dm_room = AsyncMock(return_value=True)
+        ev = self._mk_event(
+            sender="@alice:example.org",
+            body="hello bot",
+            room_id="!dm:example.org",
+        )
+        await self.adapter._on_room_message(ev)
+        self.adapter._handle_text_message.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_configured_bridge_pattern_is_dropped(self):
+        self.adapter._ignored_user_patterns = [re.compile(r"^@telegram_")]
+        ev = self._mk_event(sender="@telegram_123:example.org", body="hello bot")
+        await self.adapter._on_room_message(ev)
+        self.adapter._handle_text_message.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_notice_message_is_dropped_by_default(self):
+        ev = self._mk_event(
+            sender="@alice:example.org",
+            body="bot notice",
+            msgtype="m.notice",
+        )
+        await self.adapter._on_room_message(ev)
+        self.adapter._handle_text_message.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_notice_message_can_be_enabled(self):
+        self.adapter._process_notices = True
+        ev = self._mk_event(
+            sender="@alice:example.org",
+            body="human-authored notice",
+            msgtype="m.notice",
+        )
+        await self.adapter._on_room_message(ev)
+        self.adapter._handle_text_message.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_duplicate_event_id_dropped(self):
+        ev1 = self._mk_event(sender="@alice:example.org", body="hello bot", event_id="$dup")
+        ev2 = self._mk_event(sender="@alice:example.org", body="hello again bot", event_id="$dup")
+
+        await self.adapter._on_room_message(ev1)
+        await self.adapter._on_room_message(ev2)
+
+        self.adapter._handle_text_message.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_old_startup_event_dropped(self):
+        now = time.time()
+        self.adapter._startup_ts = now
+        ev = self._mk_event(
+            sender="@alice:example.org",
+            body="hello bot",
+            event_id="$old",
+            ts=now - 60,
+        )
+
+        await self.adapter._on_room_message(ev)
+
+        self.adapter._handle_text_message.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_seconds_timestamp_reaches_text_handler(self):
+        now = time.time()
+        self.adapter._startup_ts = now - 10
+        ev = self._mk_event(
+            sender="@alice:example.org",
+            body="hello bot",
+            event_id="$seconds-filter",
+            ts=now,
+        )
+        ev.timestamp = now
+        ev.server_timestamp = now
+
+        await self.adapter._on_room_message(ev)
+
+        self.adapter._handle_text_message.assert_awaited_once()
+
+
+class TestMatrixRequireMention:
+    """require_mention should honor config.extra like thread_require_mention."""
+
+    def test_require_mention_from_config_extra_false(self):
+        from gateway.platforms.matrix import MatrixAdapter
+
+        config = PlatformConfig(
+            enabled=True,
+            token="syt_test",
+            extra={
+                "homeserver": "https://matrix.example.org",
+                "require_mention": False,
+            },
+        )
+        adapter = MatrixAdapter(config)
+        assert adapter._require_mention is False
+
+    def test_require_mention_from_env_when_extra_unset(self, monkeypatch):
+        monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
+
+        from gateway.platforms.matrix import MatrixAdapter
+
+        config = PlatformConfig(
+            enabled=True,
+            token="syt_test",
+            extra={"homeserver": "https://matrix.example.org"},
+        )
+        adapter = MatrixAdapter(config)
+        assert adapter._require_mention is False
+
+    def test_require_mention_config_takes_precedence_over_env(self, monkeypatch):
+        monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "true")
+
+        from gateway.platforms.matrix import MatrixAdapter
+
+        config = PlatformConfig(
+            enabled=True,
+            token="syt_test",
+            extra={
+                "homeserver": "https://matrix.example.org",
+                "require_mention": False,
+            },
+        )
+        adapter = MatrixAdapter(config)
+        assert adapter._require_mention is False
+
+    @pytest.mark.asyncio
+    async def test_require_mention_false_allows_unmentioned_group_message(self):
+        from gateway.platforms.matrix import MatrixAdapter
+
+        config = PlatformConfig(
+            enabled=True,
+            token="syt_test",
+            extra={
+                "homeserver": "https://matrix.example.org",
+                "user_id": "@bot:example.org",
+                "require_mention": False,
+            },
+        )
+        adapter = MatrixAdapter(config)
+        adapter._is_dm_room = AsyncMock(return_value=False)
+        adapter._resolve_room_identity = AsyncMock(
+            return_value=MagicMock(display_name="Project Room")
+        )
+        adapter._get_display_name = AsyncMock(return_value="Alice")
+        adapter._background_read_receipt = MagicMock()
+
+        ctx = await adapter._resolve_message_context(
+            room_id="!project:example.org",
+            sender="@alice:example.org",
+            event_id="$unmentioned",
+            body="hello there",
+            source_content={"body": "hello there"},
+            relates_to={},
+        )
+
+        assert ctx is not None
+
+
+class TestMatrixFreeResponsePolicy:
+    def setup_method(self):
+        self.adapter = _make_adapter()
+        self.adapter._user_id = "@bot:example.org"
+        self.adapter._require_mention = True
+        self.adapter._free_rooms = {"!free:example.org"}
+        self.adapter._is_dm_room = AsyncMock(return_value=False)
+        self.adapter._resolve_room_identity = AsyncMock(
+            return_value=MagicMock(display_name="Free Room")
+        )
+        self.adapter._get_display_name = AsyncMock(return_value="Alice")
+        self.adapter._background_read_receipt = MagicMock()
+
+    @pytest.mark.asyncio
+    async def test_free_response_room_allows_unmentioned_message(self):
+        ctx = await self.adapter._resolve_message_context(
+            room_id="!free:example.org",
+            sender="@alice:example.org",
+            event_id="$free",
+            body="hello there",
+            source_content={"body": "hello there"},
+            relates_to={},
+        )
+
+        assert ctx is not None
+
+    @pytest.mark.asyncio
+    async def test_non_free_room_requires_mention(self):
+        ctx = await self.adapter._resolve_message_context(
+            room_id="!locked:example.org",
+            sender="@alice:example.org",
+            event_id="$locked",
+            body="hello there",
+            source_content={"body": "hello there"},
+            relates_to={},
+        )
+
+        assert ctx is None
+
 
 class TestMatrixClockSkewWarning:
     """Clock-skew detector for #12614.
diff --git a/tests/gateway/test_matrix_approval_reaction_fail_closed.py b/tests/gateway/test_matrix_approval_reaction_fail_closed.py
index c9b5277ee6a..be181f62e08 100644
--- a/tests/gateway/test_matrix_approval_reaction_fail_closed.py
+++ b/tests/gateway/test_matrix_approval_reaction_fail_closed.py
@@ -28,13 +28,38 @@ def _stub_mautrix():
         sys.modules.setdefault(sub, types.ModuleType(sub))
     sys.modules.setdefault("mautrix", stub)
     m = sys.modules["mautrix.types"]
-    for attr in (
-        "ContentURI", "EventID", "EventType", "PaginationDirection",
-        "PresenceState", "RoomCreatePreset", "RoomID", "SyncToken",
-        "TrustState", "UserID",
-    ):
-        if not hasattr(m, attr):
-            setattr(m, attr, str)
+
+    class EventType:
+        ROOM_MESSAGE = "m.room.message"
+        REACTION = "m.reaction"
+        ROOM_ENCRYPTED = "m.room.encrypted"
+        ROOM_NAME = "m.room.name"
+
+    class PaginationDirection:
+        BACKWARD = "b"
+        FORWARD = "f"
+
+    class PresenceState:
+        ONLINE = "online"
+        OFFLINE = "offline"
+        UNAVAILABLE = "unavailable"
+
+    class RoomCreatePreset:
+        PRIVATE = "private_chat"
+        PUBLIC = "public_chat"
+        TRUSTED_PRIVATE = "trusted_private_chat"
+
+    class TrustState:
+        UNVERIFIED = 0
+        VERIFIED = 1
+
+    for attr in ("ContentURI", "EventID", "RoomID", "SyncToken", "UserID"):
+        setattr(m, attr, str)
+    m.EventType = EventType
+    m.PaginationDirection = PaginationDirection
+    m.PresenceState = PresenceState
+    m.RoomCreatePreset = RoomCreatePreset
+    m.TrustState = TrustState
 
 
 _stub_mautrix()
diff --git a/tests/gateway/test_matrix_exec_approval.py b/tests/gateway/test_matrix_exec_approval.py
index a7afe912cba..f3a8eaf86ca 100644
--- a/tests/gateway/test_matrix_exec_approval.py
+++ b/tests/gateway/test_matrix_exec_approval.py
@@ -27,9 +27,9 @@ class TestMatrixExecApprovalReactions:
         assert result.success is True
         assert adapter._approval_prompt_by_session["sess-1"] == "$evt1"
         assert adapter._approval_prompts_by_event["$evt1"].session_key == "sess-1"
-        assert adapter._send_reaction.await_count == 2
+        assert adapter._send_reaction.await_count == 3
         emojis = [call.args[2] for call in adapter._send_reaction.await_args_list]
-        assert emojis == ["✅", "❎"]
+        assert emojis == ["✅", "♾️", "❌"]
 
     @pytest.mark.asyncio
     async def test_reaction_resolves_pending_approval(self, monkeypatch):
diff --git a/tests/gateway/test_matrix_project_context_isolation.py b/tests/gateway/test_matrix_project_context_isolation.py
new file mode 100644
index 00000000000..871f4a855f5
--- /dev/null
+++ b/tests/gateway/test_matrix_project_context_isolation.py
@@ -0,0 +1,510 @@
+"""Matrix Project A / Project B context-isolation regressions."""
+
+from __future__ import annotations
+
+import asyncio
+import time
+from datetime import datetime
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from gateway.config import GatewayConfig, Platform, PlatformConfig
+from gateway.platforms.base import MessageEvent
+from gateway.session import (
+    SessionContext,
+    SessionEntry,
+    SessionSource,
+    build_session_context_prompt,
+    build_session_key,
+)
+
+PROJECT_A_ROOM_ID = "!projectA:example.org"
+PROJECT_B_ROOM_ID = "!projectB:example.org"
+PROJECT_A_NAME = "Project - Project A"
+PROJECT_B_NAME = "Project - Project B"
+PROJECT_A_TOPIC = "Architecture and deploy plan for Project A"
+PROJECT_B_TOPIC = "Migration and branch plan for Project B"
+PROJECT_A_ALIAS = "#project-a:example.org"
+PROJECT_B_ALIAS = "#project-b:example.org"
+SENDER = "@alice:example.org"
+
+
+def _make_adapter():
+    from gateway.platforms.matrix import MatrixAdapter
+
+    adapter = MatrixAdapter(
+        PlatformConfig(
+            enabled=True,
+            token="test-token",
+            extra={"homeserver": "https://matrix.example.org", "user_id": "@bot:example.org"},
+        )
+    )
+    adapter._user_id = "@bot:example.org"
+    adapter._require_mention = False
+    adapter._auto_thread = False
+    adapter._matrix_session_scope = "room"
+    adapter._text_batch_delay_seconds = 0
+    adapter._background_read_receipt = MagicMock()
+    adapter._get_display_name = AsyncMock(return_value="Alice")
+    adapter._client = _FakeMatrixClient()
+    return adapter
+
+
+class _FakeMatrixClient:
+    def __init__(self):
+        self.state_store = MagicMock()
+        self.state_store.get_members = AsyncMock(return_value=["@bot:example.org", SENDER])
+
+    async def get_state_event(self, room_id, event_type):
+        rid = str(room_id)
+        state = {
+            PROJECT_A_ROOM_ID: {
+                "m.room.name": {"content": {"name": PROJECT_A_NAME}},
+                "m.room.topic": {"content": {"topic": PROJECT_A_TOPIC}},
+                "m.room.canonical_alias": {"content": {"alias": PROJECT_A_ALIAS}},
+            },
+            PROJECT_B_ROOM_ID: {
+                "m.room.name": {"content": {"name": PROJECT_B_NAME}},
+                "m.room.topic": {"content": {"topic": PROJECT_B_TOPIC}},
+                "m.room.canonical_alias": {"content": {"alias": PROJECT_B_ALIAS}},
+            },
+        }
+        value = state.get(rid, {}).get(str(event_type))
+        if value is None:
+            raise KeyError((rid, event_type))
+        return value
+
+
+async def _source_for(adapter, room_id: str, event_id: str = "$event"):
+    ctx = await adapter._resolve_message_context(
+        room_id=room_id,
+        sender=SENDER,
+        event_id=event_id,
+        body="What is next?",
+        source_content={"body": "What is next?"},
+        relates_to={},
+    )
+    assert ctx is not None
+    return ctx[-1]
+
+
+def _matrix_event(room_id: str, event_id: str, body: str = "What is next?"):
+    event = MagicMock()
+    event.room_id = room_id
+    event.sender = SENDER
+    event.event_id = event_id
+    event.timestamp = int(time.time() * 1000)
+    event.server_timestamp = event.timestamp
+    event.content = {"msgtype": "m.text", "body": body}
+    return event
+
+
+def _context_for(source: SessionSource) -> SessionContext:
+    return SessionContext(
+        source=source,
+        connected_platforms=[Platform.MATRIX],
+        home_channels={},
+        session_key=build_session_key(source),
+        session_id="session-test",
+    )
+
+
+@pytest.mark.asyncio
+async def test_matrix_source_includes_room_name_topic_and_message_id():
+    adapter = _make_adapter()
+    source = await _source_for(adapter, PROJECT_B_ROOM_ID, "$project-b-msg")
+
+    assert source.chat_id == PROJECT_B_ROOM_ID
+    assert source.chat_name == PROJECT_B_NAME
+    assert source.chat_topic == PROJECT_B_TOPIC
+    assert source.guild_id == "example.org"
+    assert source.message_id == "$project-b-msg"
+    assert source.parent_chat_id is None
+
+
+@pytest.mark.asyncio
+async def test_matrix_project_a_and_project_b_have_distinct_session_keys():
+    adapter = _make_adapter()
+    source_a = await _source_for(adapter, PROJECT_A_ROOM_ID, "$a")
+    source_b = await _source_for(adapter, PROJECT_B_ROOM_ID, "$b")
+
+    assert source_a.chat_id != source_b.chat_id
+    assert source_a.chat_name == PROJECT_A_NAME
+    assert source_b.chat_name == PROJECT_B_NAME
+    assert build_session_key(source_a) != build_session_key(source_b)
+
+
+@pytest.mark.asyncio
+async def test_matrix_project_b_prompt_contains_project_b_not_project_a():
+    adapter = _make_adapter()
+    source_b = await _source_for(adapter, PROJECT_B_ROOM_ID, "$b")
+
+    prompt = build_session_context_prompt(_context_for(source_b))
+
+    assert PROJECT_B_NAME in prompt
+    assert PROJECT_B_TOPIC in prompt
+    assert PROJECT_B_ROOM_ID in prompt
+    assert "Matrix room boundary" in prompt
+    assert PROJECT_A_NAME not in prompt
+    assert PROJECT_A_TOPIC not in prompt
+
+
+@pytest.mark.asyncio
+async def test_matrix_project_context_survives_sequential_messages():
+    adapter = _make_adapter()
+    adapter._matrix_session_scope = "room"
+    first = await _source_for(adapter, PROJECT_B_ROOM_ID, "$b1")
+    second = await _source_for(adapter, PROJECT_B_ROOM_ID, "$b2")
+
+    assert first.thread_id is None
+    assert second.thread_id is None
+    assert first.chat_name == PROJECT_B_NAME
+    assert second.chat_name == PROJECT_B_NAME
+    assert build_session_key(first) == build_session_key(second)
+
+
+@pytest.mark.asyncio
+async def test_matrix_session_scope_auto_and_thread_preserve_synthetic_threads():
+    adapter = _make_adapter()
+    adapter._auto_thread = True
+    adapter._matrix_session_scope = "auto"
+    auto_source = await _source_for(adapter, PROJECT_B_ROOM_ID, "$auto")
+    assert auto_source.thread_id == "$auto"
+
+    adapter._matrix_session_scope = "thread"
+    thread_source = await _source_for(adapter, PROJECT_B_ROOM_ID, "$thread")
+    assert thread_source.thread_id == "$thread"
+
+    real_thread = await adapter._resolve_message_context(
+        room_id=PROJECT_B_ROOM_ID,
+        sender=SENDER,
+        event_id="$reply",
+        body="thread reply",
+        source_content={"body": "thread reply"},
+        relates_to={"rel_type": "m.thread", "event_id": "$root"},
+    )
+    assert real_thread is not None
+    assert real_thread[-1].thread_id == "$root"
+
+
+@pytest.mark.asyncio
+async def test_matrix_project_context_survives_concurrent_messages():
+    from gateway.run import GatewayRunner
+    from gateway.session_context import get_session_env
+
+    async def observe(room_id: str):
+        adapter = _make_adapter()
+        source = await _source_for(adapter, room_id, f"${room_id}")
+        context = _context_for(source)
+        runner = object.__new__(GatewayRunner)
+        tokens = runner._set_session_env(context)
+        try:
+            await asyncio.sleep(0)
+            return SimpleNamespace(
+                chat_id=get_session_env("HERMES_SESSION_CHAT_ID"),
+                chat_name=get_session_env("HERMES_SESSION_CHAT_NAME"),
+                session_key=get_session_env("HERMES_SESSION_KEY"),
+            )
+        finally:
+            runner._clear_session_env(tokens)
+
+    observed_a, observed_b = await asyncio.gather(
+        observe(PROJECT_A_ROOM_ID),
+        observe(PROJECT_B_ROOM_ID),
+    )
+
+    assert observed_a.chat_id == PROJECT_A_ROOM_ID
+    assert observed_b.chat_id == PROJECT_B_ROOM_ID
+    assert observed_a.chat_name == PROJECT_A_NAME
+    assert observed_b.chat_name == PROJECT_B_NAME
+    assert observed_a.session_key != observed_b.session_key
+
+
+@pytest.mark.asyncio
+async def test_matrix_inbound_handler_emits_project_b_metadata_not_project_a():
+    adapter = _make_adapter()
+    captured = []
+
+    async def capture(event):
+        captured.append(event)
+
+    adapter.handle_message = capture
+
+    await adapter._on_room_message(_matrix_event(PROJECT_B_ROOM_ID, "$project-b"))
+
+    assert len(captured) == 1
+    source = captured[0].source
+    assert source.chat_id == PROJECT_B_ROOM_ID
+    assert source.chat_name == PROJECT_B_NAME
+    assert source.chat_topic == PROJECT_B_TOPIC
+    assert source.message_id == "$project-b"
+    assert PROJECT_A_NAME not in repr(source.to_dict())
+
+
+@pytest.mark.asyncio
+async def test_matrix_inbound_handler_keeps_project_a_and_b_distinct():
+    adapter = _make_adapter()
+    captured = []
+
+    async def capture(event):
+        captured.append(event)
+
+    adapter.handle_message = capture
+
+    await adapter._on_room_message(_matrix_event(PROJECT_A_ROOM_ID, "$project-a", "A"))
+    await adapter._on_room_message(_matrix_event(PROJECT_B_ROOM_ID, "$project-b", "B"))
+
+    assert [event.source.chat_id for event in captured] == [
+        PROJECT_A_ROOM_ID,
+        PROJECT_B_ROOM_ID,
+    ]
+    assert [event.source.chat_name for event in captured] == [
+        PROJECT_A_NAME,
+        PROJECT_B_NAME,
+    ]
+    assert build_session_key(captured[0].source) != build_session_key(captured[1].source)
+
+
+def test_matrix_room_scope_group_sessions_per_user_true_separates_users():
+    alice = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
+    bob = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
+    bob.user_id = "@bob:example.org"
+    alice.thread_id = None
+    bob.thread_id = None
+
+    assert build_session_key(alice, group_sessions_per_user=True) != build_session_key(
+        bob,
+        group_sessions_per_user=True,
+    )
+
+
+def test_matrix_room_scope_group_sessions_per_user_false_shares_room():
+    alice = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
+    bob = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
+    bob.user_id = "@bob:example.org"
+    alice.thread_id = None
+    bob.thread_id = None
+
+    assert build_session_key(alice, group_sessions_per_user=False) == build_session_key(
+        bob,
+        group_sessions_per_user=False,
+    )
+
+
+def _make_matrix_source(room_id: str, room_name: str, topic: str) -> SessionSource:
+    return SessionSource(
+        platform=Platform.MATRIX,
+        chat_id=room_id,
+        chat_name=room_name,
+        chat_type="group",
+        user_id=SENDER,
+        user_name="Alice",
+        chat_topic=topic,
+    )
+
+
+def _entry(source: SessionSource, session_id: str, title: str | None = None) -> SessionEntry:
+    return SessionEntry(
+        session_key=build_session_key(source),
+        session_id=session_id,
+        created_at=datetime.now(),
+        updated_at=datetime.now(),
+        origin=source,
+        display_name=title or source.chat_name,
+        platform=Platform.MATRIX,
+        chat_type="group",
+    )
+
+
+def _make_runner(current_source: SessionSource, entries: list[SessionEntry]):
+    from gateway.run import GatewayRunner
+
+    runner = object.__new__(GatewayRunner)
+    runner.config = GatewayConfig(platforms={Platform.MATRIX: PlatformConfig(enabled=True)})
+    adapter = MagicMock()
+    adapter._matrix_session_scope = "room"
+    runner.adapters = {Platform.MATRIX: adapter}
+    runner.session_store = MagicMock()
+    runner.session_store._entries = {entry.session_key: entry for entry in entries}
+    current = next((e for e in entries if e.origin and e.origin.chat_id == current_source.chat_id), entries[0])
+    runner.session_store.get_or_create_session.return_value = current
+    runner.session_store.switch_session.return_value = current
+    runner.session_store.load_transcript.return_value = [{"role": "user", "content": "hello"}]
+    runner._running_agents = {}
+    runner._session_run_generation = {}
+    runner._pending_messages = {}
+    runner._pending_approvals = {}
+    runner._release_running_agent_state = MagicMock()
+    runner._clear_session_boundary_security_state = MagicMock()
+    runner._evict_cached_agent = MagicMock()
+    runner._queue_depth = MagicMock(return_value=0)
+    runner._session_db = MagicMock()
+    runner._session_db.list_sessions_rich.return_value = [
+        {"id": entry.session_id, "title": entry.display_name, "preview": ""}
+        for entry in entries
+    ]
+    runner._session_db.resolve_resume_session_id.side_effect = lambda sid: sid
+    runner._session_db.get_session_title.side_effect = lambda sid: {
+        entry.session_id: entry.display_name for entry in entries
+    }.get(sid)
+    runner._session_db.get_session.return_value = None
+    return runner
+
+
+def _event(text: str, source: SessionSource) -> MessageEvent:
+    return MessageEvent(text=text, source=source, message_id="$cmd")
+
+
+@pytest.mark.asyncio
+async def test_matrix_status_reports_current_matrix_room_scope():
+    source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
+    source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
+    entry_b = _entry(source_b, "session-b", "Project B Plan")
+    runner = _make_runner(source_b, [_entry(source_a, "session-a", "Project A Plan"), entry_b])
+
+    result = await runner._handle_status_command(_event("/status", source_b))
+
+    assert "Matrix scope:" in result
+    assert PROJECT_B_NAME in result
+    assert PROJECT_B_ROOM_ID in result
+    assert "session_scope: room" in result
+    session_key = build_session_key(source_b)
+    assert session_key not in result
+    assert session_key[:8] not in result
+    assert "session_key: sha256:" in result
+    assert PROJECT_A_NAME not in result
+    assert PROJECT_A_ROOM_ID not in result
+
+
+@pytest.mark.asyncio
+async def test_matrix_resume_does_not_cross_rooms_by_default():
+    source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
+    source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
+    entry_a = _entry(source_a, "session-a", "Project A Plan")
+    entry_b = _entry(source_b, "session-b", "Project B Plan")
+    runner = _make_runner(source_b, [entry_a, entry_b])
+    runner._session_db.resolve_session_by_title.return_value = "session-a"
+
+    result = await runner._handle_resume_command(_event("/resume Project A Plan", source_b))
+
+    assert "blocked" in result
+    assert PROJECT_A_NAME in result
+    runner.session_store.switch_session.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_matrix_resume_allows_same_room_session():
+    source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
+    entry_b = _entry(source_b, "session-b-old", "Project B Plan")
+    runner = _make_runner(source_b, [entry_b])
+    runner.session_store.get_or_create_session.return_value = _entry(
+        source_b, "session-b-current", "Current Project B"
+    )
+    runner.session_store.switch_session.return_value = entry_b
+    runner._session_db.resolve_session_by_title.return_value = "session-b-old"
+
+    result = await runner._handle_resume_command(_event("/resume Project B Plan", source_b))
+
+    assert "Resumed session" in result
+    runner.session_store.switch_session.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_matrix_resume_quoted_title_same_room():
+    source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
+    entry_b = _entry(source_b, "session-b-old", "Project B Plan")
+    runner = _make_runner(source_b, [entry_b])
+    runner.session_store.get_or_create_session.return_value = _entry(
+        source_b, "session-b-current", "Current Project B"
+    )
+    runner.session_store.switch_session.return_value = entry_b
+    runner._session_db.resolve_session_by_title.return_value = "session-b-old"
+
+    result = await runner._handle_resume_command(
+        _event('/resume "Project B Plan"', source_b)
+    )
+
+    assert "Resumed session" in result
+    runner._session_db.resolve_session_by_title.assert_called_once_with("Project B Plan")
+
+
+@pytest.mark.asyncio
+async def test_matrix_resume_quoted_title_cross_room_blocked():
+    source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
+    source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
+    entry_a = _entry(source_a, "session-a", "Project A Plan")
+    entry_b = _entry(source_b, "session-b", "Project B Plan")
+    runner = _make_runner(source_b, [entry_a, entry_b])
+    runner._session_db.resolve_session_by_title.return_value = "session-a"
+
+    result = await runner._handle_resume_command(
+        _event('/resume "Project A Plan"', source_b)
+    )
+
+    assert "blocked" in result
+    runner.session_store.switch_session.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_matrix_resume_malformed_quote_returns_helpful_error():
+    source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
+    runner = _make_runner(source_b, [_entry(source_b, "session-b", "Project B Plan")])
+
+    result = await runner._handle_resume_command(
+        _event('/resume "Project B Plan', source_b)
+    )
+
+    assert "Could not parse" in result
+    assert "quotes" in result
+
+
+@pytest.mark.asyncio
+async def test_matrix_resume_cross_room_requires_explicit_flag_and_warns():
+    source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
+    source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
+    entry_a = _entry(source_a, "session-a", "Project A Plan")
+    entry_b = _entry(source_b, "session-b", "Project B Plan")
+    runner = _make_runner(source_b, [entry_a, entry_b])
+    runner.session_store.switch_session.return_value = entry_a
+    runner._session_db.resolve_session_by_title.return_value = "session-a"
+
+    result = await runner._handle_resume_command(
+        _event("/resume --cross-room Project A Plan", source_b)
+    )
+
+    assert "Cross-room resume" in result
+    assert PROJECT_B_NAME in result
+    runner.session_store.switch_session.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_matrix_resume_lists_only_current_room_by_default():
+    source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
+    source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
+    runner = _make_runner(
+        source_b,
+        [_entry(source_a, "session-a", "Project A Plan"), _entry(source_b, "session-b", "Project B Plan")],
+    )
+
+    result = await runner._handle_resume_command(_event("/resume", source_b))
+
+    assert "Project B Plan" in result
+    assert "Project A Plan" not in result
+
+
+@pytest.mark.asyncio
+async def test_matrix_resume_all_lists_room_names():
+    source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
+    source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
+    runner = _make_runner(
+        source_b,
+        [_entry(source_a, "session-a", "Project A Plan"), _entry(source_b, "session-b", "Project B Plan")],
+    )
+
+    result = await runner._handle_resume_command(_event("/resume --all", source_b))
+
+    assert "Project A Plan" in result
+    assert PROJECT_A_NAME in result
+    assert "Project B Plan" in result
diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py
index 9b5fff64214..239dc28c8fc 100644
--- a/tests/gateway/test_session.py
+++ b/tests/gateway/test_session.py
@@ -611,6 +611,30 @@ class TestSessionStoreSwitchSession:
         db.close()
 
 
+class TestSessionStoreLookupBySessionId:
+    @pytest.fixture()
+    def store(self, tmp_path):
+        config = GatewayConfig()
+        with patch("gateway.session.SessionStore._ensure_loaded"):
+            s = SessionStore(sessions_dir=tmp_path, config=config)
+        s._db = None
+        s._loaded = True
+        return s
+
+    def test_returns_active_entry_for_persisted_session_id(self, store):
+        source = SessionSource(
+            platform=Platform.MATRIX,
+            chat_id="!room:example.org",
+            chat_type="group",
+            user_id="@alice:example.org",
+        )
+        entry = store.get_or_create_session(source)
+
+        assert store.lookup_by_session_id(entry.session_id) is entry
+        assert store.lookup_by_session_id("missing") is None
+        assert store.lookup_by_session_id("") is None
+
+
 class TestWhatsAppSessionKeyConsistency:
     """Regression: WhatsApp session keys must collapse JID/LID aliases to a
     single stable identity for both DM chat_ids and group participant_ids."""
diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md
index cfd3001e247..a879a8db9c5 100644
--- a/website/docs/reference/environment-variables.md
+++ b/website/docs/reference/environment-variables.md
@@ -397,15 +397,31 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
 | `MATRIX_USER_ID` | Matrix user ID (e.g. `@hermes:matrix.org`) — required for password login, optional with access token |
 | `MATRIX_PASSWORD` | Matrix password (alternative to access token) |
 | `MATRIX_ALLOWED_USERS` | Comma-separated Matrix user IDs allowed to message the bot (e.g. `@alice:matrix.org`) |
+| `MATRIX_ALLOWED_ROOMS` | Comma-separated Matrix room IDs allowed to trigger bot responses |
 | `MATRIX_HOME_ROOM` | Room ID for proactive message delivery (e.g. `!abc123:matrix.org`) |
 | `MATRIX_ENCRYPTION` | Enable end-to-end encryption (`true`/`false`, default: `false`) |
+| `MATRIX_E2EE_MODE` | Matrix E2EE behavior: `off`, `optional`, or `required`. Overrides `MATRIX_ENCRYPTION` when set. |
 | `MATRIX_DEVICE_ID` | Stable Matrix device ID for E2EE persistence across restarts (e.g. `HERMES_BOT`). Without this, E2EE keys rotate every startup and historic-room decrypt breaks. |
 | `MATRIX_REACTIONS` | Enable processing-lifecycle emoji reactions on inbound messages (default: `true`). Set to `false` to disable. |
 | `MATRIX_REQUIRE_MENTION` | Require `@mention` in rooms (default: `true`). Set to `false` to respond to all messages. |
 | `MATRIX_FREE_RESPONSE_ROOMS` | Comma-separated room IDs where bot responds without `@mention` |
+| `MATRIX_IGNORE_USER_PATTERNS` | Comma-separated regular expressions for Matrix bridge/appservice ghost user IDs to ignore |
+| `MATRIX_PROCESS_NOTICES` | Process inbound Matrix `m.notice` events (default: `false`) |
+| `MATRIX_SESSION_SCOPE` | Matrix session scope for project rooms: `auto`, `room`, or `thread` (default: `auto`) |
+| `MATRIX_TOOLS_ALLOW_CROSS_ROOM` | Allow Matrix tools to target explicit rooms other than the current room (default: `false`) |
+| `MATRIX_TOOLS_ALLOW_CROSS_ROOM_DESTRUCTIVE` | Allow cross-room Matrix redaction/invite-like tools; requires `MATRIX_TOOLS_ALLOW_CROSS_ROOM=true` (default: `false`) |
+| `MATRIX_TOOLS_ALLOW_REDACTION` | Allow Matrix message redaction tool execution (default: `false`) |
+| `MATRIX_TOOLS_ALLOW_INVITES` | Allow Matrix invite tool execution (default: `false`) |
+| `MATRIX_TOOLS_ALLOW_ROOM_CREATE` | Allow Matrix room creation tool execution (default: `false`) |
+| `MATRIX_ALLOW_ROOM_MENTIONS` | Allow outbound `@room` mentions to notify all room members (default: `false`) |
 | `MATRIX_AUTO_THREAD` | Auto-create threads for room messages (default: `true`) |
 | `MATRIX_DM_MENTION_THREADS` | Create a thread when bot is `@mentioned` in a DM (default: `false`) |
+| `MATRIX_APPROVAL_REQUIRE_SENDER` | Require approval/model-picker reactions to come from the original requester when known (default: `true`) |
+| `MATRIX_APPROVAL_TIMEOUT_SECONDS` | Timeout for Matrix reaction approval/model-picker prompts (default: `300`) |
+| `MATRIX_ALLOW_PUBLIC_ROOMS` | Allow Matrix room-creation tools to create public rooms (default: `false`) |
+| `MATRIX_MAX_MEDIA_BYTES` | Maximum Matrix media upload/download size in bytes (default: `104857600`) |
 | `MATRIX_RECOVERY_KEY` | Recovery key for cross-signing verification after device key rotation. Recommended for E2EE setups with cross-signing enabled. |
+| `MATRIX_RECOVERY_KEY_OUTPUT_FILE` | Optional one-time path for a generated Matrix recovery key. Created with mode `0600` and never overwritten. |
 | `HASS_TOKEN` | Home Assistant Long-Lived Access Token (enables HA platform + tools) |
 | `HASS_URL` | Home Assistant URL (default: `http://homeassistant.local:8123`) |
 | `WEBHOOK_ENABLED` | Enable the webhook platform adapter (`true`/`false`) |
diff --git a/website/docs/user-guide/messaging/matrix.md b/website/docs/user-guide/messaging/matrix.md
index 9974ff7b918..f69de76a876 100644
--- a/website/docs/user-guide/messaging/matrix.md
+++ b/website/docs/user-guide/messaging/matrix.md
@@ -21,12 +21,36 @@ Before setup, here's the part most people want to know: how Hermes behaves once
 | **Threads** | Hermes supports Matrix threads (MSC3440). If you reply in a thread, Hermes keeps the thread context isolated from the main room timeline. Threads where the bot has already participated do not require a mention. |
 | **Auto-threading** | By default, Hermes auto-creates a thread for each message it responds to in a room. This keeps conversations isolated. Set `MATRIX_AUTO_THREAD=false` to disable. Set `MATRIX_DM_AUTO_THREAD=true` (default false) to also auto-create threads for DM messages — this is distinct from `MATRIX_DM_MENTION_THREADS`, which only starts a thread when the bot is `@mentioned` in a DM. |
 | **Commands** | Hermes accepts normal `/commands` when your Matrix client sends them. If your client reserves `/` for local commands, use `!commands` instead; Hermes normalizes known `!command` aliases to `/command`. |
+| **Interactive controls** | Dangerous-command approval and `/model` selection can use Matrix reactions. Approval reactions can be limited to the user who requested the action. |
+| **Thinking and tool activity** | Matrix uses threaded, editable thinking/tool-activity panes when gateway progress is enabled, so updates do not flood the main room timeline. |
 | **Shared rooms with multiple users** | By default, Hermes isolates session history per user inside the room. Two people talking in the same room do not share one transcript unless you explicitly disable that. |
 
 :::tip
 The bot automatically joins rooms when invited. Just invite the bot's Matrix user to any room and it will join and start responding.
 :::
 
+## Capability Matrix
+
+This table is backed by the Matrix adapter capability declaration and Matrix test
+coverage. E2EE is mode-based because deployments choose whether encrypted rooms
+are disabled, opportunistic, or required.
+
+| Capability | Matrix |
+|------------|--------|
+| text | yes |
+| threads | yes |
+| reactions | yes |
+| approvals | yes |
+| model picker | yes |
+| thinking panes | yes |
+| images | yes |
+| multiple images | yes |
+| files | yes |
+| voice/audio | yes |
+| video | yes |
+| E2EE | off / optional / required |
+| diagnostics | yes |
+
 ### Session Model in Matrix
 
 By default:
@@ -60,8 +84,17 @@ You can configure mention and auto-threading behavior via environment variables
 ```yaml
 matrix:
   require_mention: true           # Require @mention in rooms (default: true)
+  allowed_users:                  # Matrix users allowed to trigger agent turns
+    - "@alice:matrix.org"
+  allowed_rooms:                  # Matrix rooms allowed to trigger agent turns
+    - "!abc123:matrix.org"
   free_response_rooms:            # Rooms exempt from mention requirement
     - "!abc123:matrix.org"
+  ignore_user_patterns:           # Bridge/appservice ghost users to ignore
+    - "^@telegram_"
+    - "^@whatsapp_"
+  process_notices: false          # Ignore m.notice by default
+  session_scope: room             # auto|room|thread; room is recommended for project rooms
   auto_thread: true               # Auto-create threads for responses (default: true)
   dm_mention_threads: false       # Create thread when @mentioned in DM (default: false)
 ```
@@ -70,20 +103,60 @@ Or via environment variables:
 
 ```bash
 MATRIX_REQUIRE_MENTION=true
+MATRIX_ALLOWED_USERS=@alice:matrix.org
+MATRIX_ALLOWED_ROOMS=!abc123:matrix.org
 MATRIX_FREE_RESPONSE_ROOMS=!abc123:matrix.org,!def456:matrix.org
+MATRIX_IGNORE_USER_PATTERNS='^@telegram_,^@whatsapp_'
+MATRIX_PROCESS_NOTICES=false
+MATRIX_SESSION_SCOPE=room       # recommended for stable project-room context
 MATRIX_AUTO_THREAD=true
 MATRIX_DM_MENTION_THREADS=false
 MATRIX_REACTIONS=true          # default: true — emoji reactions during processing
+MATRIX_ALLOW_ROOM_MENTIONS=false
 ```
 
 :::tip Disabling reactions
 `MATRIX_REACTIONS=false` turns off the processing-lifecycle emoji reactions (👀/✅/❌) the bot posts on inbound messages. Useful for rooms where reaction events are noisy or aren't supported by all participating clients.
 :::
 
+:::tip Room-wide mentions
+Hermes sends structured Matrix user mentions for explicit Matrix IDs such as `@alice:example.org`. Room-wide `@room` notifications are disabled by default; set `MATRIX_ALLOW_ROOM_MENTIONS=true` only in rooms where the bot is allowed to notify everyone.
+:::
+
 :::note
 If you are upgrading from a version that did not have `MATRIX_REQUIRE_MENTION`, the bot previously responded to all messages in rooms. To preserve that behavior, set `MATRIX_REQUIRE_MENTION=false`.
 :::
 
+### Project Room Isolation
+
+If you use the same Matrix bot in multiple project rooms, configure stable
+room-scoped sessions:
+
+```bash
+MATRIX_SESSION_SCOPE=room
+MATRIX_AUTO_THREAD=false
+```
+
+`MATRIX_SESSION_SCOPE` accepts:
+
+| Scope | Behavior |
+|-------|----------|
+| `auto` | Backward-compatible default. Existing `MATRIX_AUTO_THREAD` behavior controls synthetic threads. |
+| `room` | Unthreaded room messages stay in one stable room session. Real Matrix threads still use their thread root. |
+| `thread` | Unthreaded room messages synthesize a thread/session from the triggering event ID. |
+
+Hermes now includes the current Matrix room name, room ID, topic, message ID,
+and a Matrix room-boundary note in the agent prompt. `/status` also shows the
+current Matrix room/session scope, and `/resume` will not silently resume a
+named session from another Matrix room unless you explicitly use
+`/resume --cross-room `.
+
+`MATRIX_SESSION_SCOPE=room` controls the room/thread lane. The existing
+`group_sessions_per_user` setting still controls whether users inside that room
+share the lane. With `group_sessions_per_user: true` (default), Alice and Bob get
+separate Project B sessions. With `group_sessions_per_user: false`, the room has
+one shared Project B transcript.
+
 This guide walks you through the full setup process — from creating your bot account to sending your first message.
 
 ## Step 1: Create a Bot Account
@@ -196,6 +269,9 @@ MATRIX_ACCESS_TOKEN=***
 # Security: restrict who can interact with the bot
 MATRIX_ALLOWED_USERS=@alice:matrix.example.org
 
+# Optional: restrict which rooms can trigger the bot
+MATRIX_ALLOWED_ROOMS=!abc123:matrix.example.org
+
 # Multiple allowed users (comma-separated)
 # MATRIX_ALLOWED_USERS=@alice:matrix.example.org,@bob:matrix.example.org
 ```
@@ -212,6 +288,45 @@ MATRIX_PASSWORD=***
 MATRIX_ALLOWED_USERS=@alice:matrix.example.org
 ```
 
+## Private Deployment Hardening
+
+For private Matrix deployments, set both user and room allowlists. If
+`MATRIX_ALLOWED_USERS` is unset, any sender who can reach the bot in a joined
+room can trigger an agent turn. If `MATRIX_ALLOWED_ROOMS` is unset, any room the
+bot joins can trigger an agent turn. A locked-down deployment should set both:
+
+```bash
+MATRIX_ALLOWED_USERS=@alice:matrix.example.org,@bob:matrix.example.org
+MATRIX_ALLOWED_ROOMS=!ops:matrix.example.org,!dmroom:matrix.example.org
+```
+
+Bridge and appservice deployments need extra loop protection. Hermes always
+ignores its own events, Matrix appservice-style users whose localpart starts
+with `_`, duplicate event IDs, old startup events, edit replacement events, and
+`m.notice` events by default. Add deployment-specific bridge ghost patterns when
+your bridge uses a different naming convention:
+
+```bash
+MATRIX_IGNORE_USER_PATTERNS='^@telegram_,^@slack_,^@whatsapp_'
+```
+
+Only enable notices when a trusted human workflow really sends `m.notice`:
+
+```bash
+MATRIX_PROCESS_NOTICES=true
+```
+
+Outbound whole-room notifications are disabled by default. Keep
+`MATRIX_ALLOW_ROOM_MENTIONS=false` unless the bot is explicitly allowed to wake
+the whole room with `@room`.
+
+Diagnostics and debug payloads redact Matrix access tokens, recovery keys,
+device identifiers, and message bodies. Media downloads are limited to Matrix
+`mxc://` content URIs and rejected when they exceed `MATRIX_MAX_MEDIA_BYTES`.
+Treat federated rooms and untrusted homeservers as untrusted input: keep room
+allowlists tight, prefer DMs or private rooms for tool-heavy work, and avoid
+authorizing bridge ghosts or appservice puppets as allowed users.
+
 Optional behavior settings in `~/.hermes/config.yaml`:
 
 ```yaml
@@ -268,9 +383,21 @@ sudo dnf install libolm-devel
 Add to your `~/.hermes/.env`:
 
 ```bash
-MATRIX_ENCRYPTION=true
+MATRIX_E2EE_MODE=required
 ```
 
+`MATRIX_E2EE_MODE` accepts:
+
+| Mode | Behavior |
+|------|----------|
+| `off` | Do not initialize Matrix E2EE. |
+| `optional` | Try E2EE when dependencies are available, but keep unencrypted rooms working if crypto cannot initialize. |
+| `required` | Fail closed if E2EE dependencies or crypto setup are not available. |
+
+Optional mode may fall back to non-E2EE operation when crypto setup is unavailable. Required mode fails closed instead of silently downgrading.
+
+For backwards compatibility, `MATRIX_ENCRYPTION=true` still enables required E2EE behavior.
+
 When E2EE is enabled, Hermes:
 
 - Stores encryption keys in `~/.hermes/platforms/matrix/store/` (legacy installs: `~/.hermes/matrix/store/`)
@@ -278,6 +405,65 @@ When E2EE is enabled, Hermes:
 - Decrypts incoming messages and encrypts outgoing messages automatically
 - Auto-joins encrypted rooms when invited
 
+### Matrix Tools and Controls
+
+In Matrix conversations, Hermes exposes Matrix-specific tools to the agent:
+
+- `matrix_send_reaction`
+- `matrix_redact_message`
+- `matrix_create_room`
+- `matrix_invite_user`
+- `matrix_fetch_history`
+- `matrix_set_presence`
+
+These tools are scoped to Matrix contexts and are not available in non-Matrix toolsets. Admin-style tools are disabled by default: redaction requires `MATRIX_TOOLS_ALLOW_REDACTION=true`, invites require `MATRIX_TOOLS_ALLOW_INVITES=true`, and room creation requires `MATRIX_TOOLS_ALLOW_ROOM_CREATE=true`. Public room creation also requires `MATRIX_ALLOW_PUBLIC_ROOMS=true`.
+Matrix tools are limited to the current Matrix room by default. Explicit
+cross-room targets require `MATRIX_TOOLS_ALLOW_CROSS_ROOM=true`; redaction and
+invite-like cross-room actions additionally require
+`MATRIX_TOOLS_ALLOW_CROSS_ROOM_DESTRUCTIVE=true`. If `MATRIX_ALLOWED_ROOMS` is
+set, Matrix tools may only target those rooms.
+
+Reaction controls use:
+
+- ✅ approve once
+- ♾️ approve always
+- ❌ deny
+- number reactions for `/model` choices
+
+Set `MATRIX_APPROVAL_REQUIRE_SENDER=false` if you intentionally want any authorized Matrix user in the room to operate an approval/model picker prompt. The default is requester-bound when Hermes knows who requested the action.
+
+### Media Limits
+
+Hermes uploads and downloads Matrix images, files, audio, and video through Matrix media APIs. Multiple generated images are sent as one ordered logical batch, preserving captions and thread context across the batch.
+
+By default, Matrix media over 100 MB is rejected before upload/download. Override with:
+
+```bash
+MATRIX_MAX_MEDIA_BYTES=104857600
+```
+
+Inbound media must use Matrix `mxc://` content URIs. Hermes rejects arbitrary
+HTTP(S) media URLs in Matrix events to avoid turning a federated room into an
+unrestricted downloader.
+
+## Synapse Integration Tests
+
+Hermes includes an opt-in Synapse harness for local validation:
+
+```bash
+docker compose -f tests/e2e/matrix_synapse_gateway/docker-compose.yml up -d
+HERMES_MATRIX_SYNAPSE_INTEGRATION=1 \
+  scripts/run_tests.sh -m "integration and matrix_synapse" \
+  tests/e2e/matrix_synapse_gateway/test_gateway.py
+docker compose -f tests/e2e/matrix_synapse_gateway/docker-compose.yml down -v
+```
+
+The harness creates temporary users through Synapse shared-secret registration
+and covers private-room send/receive, named-room invite/join, media
+upload/download, bot response delivery, and startup old-event filtering. E2EE
+smoke coverage is separately marked with `matrix_e2ee` so it can stay opt-in on
+developer machines.
+
 ### Cross-Signing Verification (Recommended)
 
 If your Matrix account has cross-signing enabled (the default in Element), set the recovery key so the bot can self-sign its device on startup. Without this, other Matrix clients may refuse to share encryption sessions with the bot after a device key rotation.
@@ -290,6 +476,11 @@ MATRIX_RECOVERY_KEY=EsT... your recovery key here
 
 On each startup, if `MATRIX_RECOVERY_KEY` is set, Hermes imports cross-signing keys from the homeserver's secure secret storage and signs the current device. This is idempotent and safe to leave enabled permanently.
 
+If Hermes bootstraps a new Matrix recovery key, it never logs the raw key. Set
+`MATRIX_RECOVERY_KEY_OUTPUT_FILE=/secure/path/matrix-recovery-key.txt` before
+startup to write a generated key once with file mode `0600`; the file is not
+overwritten if it already exists.
+
 :::warning[Deleting the crypto store]
 If you delete `~/.hermes/platforms/matrix/store/crypto.db`, the bot loses its encryption identity. Simply restarting with the same device ID will **not** fully recover — the homeserver still holds one-time keys signed with the old identity key, and peers cannot establish new Olm sessions.
 
@@ -406,9 +597,9 @@ such as `!important` remain normal chat messages.
 
 ### Bot is not responding to messages
 
-**Cause**: The bot hasn't joined the room, or `MATRIX_ALLOWED_USERS` doesn't include your User ID.
+**Cause**: The bot hasn't joined the room, `MATRIX_ALLOWED_USERS` doesn't include your User ID, `MATRIX_ALLOWED_ROOMS` doesn't include the room, or a room message did not mention the bot.
 
-**Fix**: Invite the bot to the room — it auto-joins on invite. Verify your User ID is in `MATRIX_ALLOWED_USERS` (use the full `@user:server` format). Restart the gateway.
+**Fix**: Invite the bot to the room — it auto-joins on invite. Verify your User ID is in `MATRIX_ALLOWED_USERS` (use the full `@user:server` format) and the room ID is in `MATRIX_ALLOWED_ROOMS` if that allowlist is configured. In rooms, mention the bot or add the room to `MATRIX_FREE_RESPONSE_ROOMS`. Restart the gateway.
 
 ### Bot joins rooms but silently drops every message (clock skew)
 
@@ -685,6 +876,21 @@ Session continuity is maintained via the `X-Hermes-Session-Id` header. The host'
 **Limitations (v1):** Tool progress messages from the remote agent are not relayed back — the user sees the streamed final response only, not individual tool calls. Dangerous command approval prompts are handled on the host side, not relayed to the Matrix user. These can be addressed in future updates.
 :::
 
+### Bot connects and sends, but ignores inbound messages
+
+**Cause**: Matrix event handlers only fire when sync payloads are dispatched through
+mautrix's `handle_sync()` machinery. A raw `client.sync()` poll that never calls
+`handle_sync()` can leave the adapter connected (send works) while inbound
+messages never reach `_on_room_message`.
+
+**Fix**: Hermes uses an explicit sync loop that calls `client.handle_sync()` on
+both the initial sync and every incremental sync response. This matches the
+diagnosis in upstream issue #7914 and closed PR #37807, but keeps Hermes's own
+background maintenance tasks (joined-room tracking, invite handling, E2EE key
+share) instead of delegating the full lifecycle to `client.start()`. If inbound
+messages still fail after a gateway restart, verify handlers are registered before
+the first sync and check logs for `sync event dispatch error`.
+
 ### Sync issues / bot falls behind
 
 **Cause**: Long-running tool executions can delay the sync loop, or the homeserver is slow.
@@ -703,10 +909,22 @@ Session continuity is maintained via the `X-Hermes-Session-Id` header. The host'
 
 **Fix**: Add your User ID to `MATRIX_ALLOWED_USERS` in `~/.hermes/.env` and restart the gateway. Use the full `@user:server` format.
 
+### Bot ignores an entire room
+
+**Cause**: `MATRIX_ALLOWED_ROOMS` is set and the current room ID is not listed, or the room requires a mention and the message did not mention the bot.
+
+**Fix**: Add the room ID to `MATRIX_ALLOWED_ROOMS`, or remove the room allowlist if this is a personal deployment. To find a Room ID in Element, open room settings and check **Advanced**.
+
+### Bridge messages loop or echo
+
+**Cause**: A bridge/appservice puppet is relaying bot output back as a new user message, or a bridge uses non-standard ghost user IDs.
+
+**Fix**: Keep bridge ghosts out of `MATRIX_ALLOWED_USERS`, add a matching `MATRIX_IGNORE_USER_PATTERNS` entry, and leave `MATRIX_PROCESS_NOTICES=false` unless notices are part of a trusted workflow.
+
 ## Security
 
 :::warning
-Always set `MATRIX_ALLOWED_USERS` to restrict who can interact with the bot. Without it, the gateway denies all users by default as a safety measure. Only add User IDs of people you trust — authorized users have full access to the agent's capabilities, including tool use and system access.
+Always set `MATRIX_ALLOWED_USERS` and, for shared/private deployments, `MATRIX_ALLOWED_ROOMS`. Without them, anyone who can message the bot in a joined room may trigger the agent. Only authorize people and rooms you trust — authorized users have full access to the agent's capabilities, including tool use and system access.
 :::
 
 For more information on securing your Hermes Agent deployment, see the [Security Guide](../security.md).