{"repo":"NousResearch/hermes-agent","public_id":"5819","display_label":"Matrix gateway sync, auth, and messaging bug fixes","summary":"A collection of bugs and fixes related to the Matrix gateway integration, covering issues with the client syncer not starting, inbound messages not being received, E2EE support, auto-join on DM invites, and various session/authentication problems.","subsystem":"gateway","counts":{"members":32,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":10,"merged_prs":7,"attempt_prs":15},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":7881},"resolution":{"state":"partially_resolved","by":"pr-7981,pr-10860,pr-27330,pr-54031,pr-56345"},"freshness":{"state":"verified","last_verified_at":"2026-07-01T11:42:33+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.057096774193548375},"fix_edges":[{"issue_number":5819,"pr_number":7881,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":5819,"pr_number":16821,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":7770,"pr_number":16821,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":7952,"pr_number":7981,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":7966,"pr_number":7981,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":8105,"pr_number":16821,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":10094,"pr_number":10860,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":10725,"pr_number":10860,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":12614,"pr_number":27330,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":13482,"pr_number":16821,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":15398,"pr_number":16821,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":46142,"pr_number":56345,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":46310,"pr_number":54031,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":5819,"title":"Bot connects and syncs old messages, but silently ignores all new messages (no log output)","url":null,"state":"closed"},{"kind":"issue","number":7914,"title":"Matrix gateway can send messages but doesn't receive/respond to incoming messages","url":null,"state":"closed"},{"kind":"issue","number":7952,"title":"[Bug]: Switch to mautrix-python breaks matrix gateway integration #2","url":null,"state":"closed"},{"kind":"issue","number":7966,"title":"gateway startup session suspension compares datetime to float","url":null,"state":"closed"},{"kind":"issue","number":8174,"title":"[Bug]: Switch to mautrix-python breaks matrix gateway integration for encrypted communication #3","url":null,"state":"closed"},{"kind":"issue","number":10094,"title":"[Bug]: Matrix bot dm cannot auto join","url":null,"state":"closed"},{"kind":"issue","number":10725,"title":"fix(matrix): register MembershipEventDispatcher for invite auto-join","url":null,"state":"closed"},{"kind":"issue","number":12614,"title":"[Bug]: Bot does not receive messages on fresh Matrix setup (no inbound events, sync stalls)","url":null,"state":"closed"},{"kind":"issue","number":46142,"title":"[Bug]: Matrix gateway — mautrix migration (PR #18505) breaks inbound message dispatch on Tuwunel homeserver","url":null,"state":"closed"},{"kind":"issue","number":46310,"title":"[Bug]: send_message Matrix (media) path reconnects + re-inits E2EE per message — exhausts recipient one-time keys and silently drops messages under burst","url":null,"state":"closed"},{"kind":"pr","number":5829,"title":"fix: apply login credentials to client after Matrix password auth","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"6101be6db40e9e9d1aa77a92293934db5987ee66","number":0},{"kind":"pr","value":"#5819","number":5819},{"kind":"pr","value":"#7881","number":7881},{"kind":"pr","value":"#7881","number":7881},{"kind":"pr","value":"#5819","number":5819}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":5830,"title":"fix(matrix): add sync timeout, callback diagnostics, and mention-drop logging","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"f223346eb","number":0},{"kind":"pr","value":"#16821","number":16821},{"kind":"pr","value":"#5819","number":5819}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":7881,"title":"fix: resolve three high-impact community bugs (#5819, #6893, #3388)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/matrix.py","run_agent.py","tests/gateway/test_matrix.py","tests/run_agent/test_run_agent_codex_responses.py","website/docs/user-guide/messaging/feishu.md"],"fix_hunks":[{"file":"gateway/platforms/matrix.py","hunks":["@@ -418,6 +418,11 @@ async def connect(self) -> bool:\n             if isinstance(sync_data, dict):\n                 rooms_join = sync_data.get(\"rooms\", {}).get(\"join\", {})\n                 self._joined_rooms = set(rooms_join.keys())\n+                # Store the next_batch token so incremental syncs start\n+                # from where the initial sync left off.\n+                nb = sync_data.get(\"next_batch\")\n+                if nb:\n+                    await client.sync_store.put_next_batch(nb)\n                 logger.info(\n                     \"Matrix: initial sync complete, joined %d rooms\",\n                     len(self._joined_rooms),","@@ -809,19 +814,40 @@ async def _send_local_file(\n \n     async def _sync_loop(self) -> None:\n         \"\"\"Continuously sync with the homeserver.\"\"\"\n+        client = self._client\n+        # Resume from the token stored during the initial sync.\n+        next_batch = await client.sync_store.get_next_batch()\n         while not self._closing:\n             try:\n-                sync_data = await self._client.sync(timeout=30000)\n+                sync_data = await client.sync(\n+                    since=next_batch, timeout=30000,\n+                )\n                 if isinstance(sync_data, dict):\n                     # Update joined rooms from sync response.\n                     rooms_join = sync_data.get(\"rooms\", {}).get(\"join\", {})\n                     if rooms_join:\n                         self._joined_rooms.update(rooms_join.keys())\n \n+                    # Advance the sync token so the next request is\n+                    # incremental instead of a full initial sync.\n+                    nb = sync_data.get(\"next_batch\")\n+                    if nb:\n+                        next_batch = nb\n+                        await client.sync_store.put_next_batch(nb)\n+\n+                    # Dispatch events to registered handlers so that\n+                    # _on_room_message / _on_reaction / _on_invite fire.\n+                    try:\n+                        tasks = client.handle_sync(sync_data)\n+                        if tasks:\n+                            await asyncio.gather(*tasks)\n+                    except Exception as exc:\n+                        logger.warning(\"Matrix: sync event dispatch error: %s\", exc)\n+\n                 # Share keys periodically if E2EE is enabled.\n-                if self._encryption and getattr(self._client, \"crypto\", None):\n+                if self._encryption and getattr(client, \"crypto\", None):\n                     try:\n-                        await self._client.crypto.share_keys()\n+                        await client.crypto.share_keys()\n                     except Exception as exc:\n                         logger.warning(\"Matrix: E2EE key share failed: %s\", exc)\n "]},{"file":"run_agent.py","hunks":["@@ -700,10 +700,14 @@ def __init__(\n         except Exception:\n             pass\n \n-        # Direct OpenAI sessions use the Responses API path.  GPT-5.x tool\n-        # calls with reasoning are rejected on /v1/chat/completions, and\n-        # Hermes is a tool-using client by default.\n-        if self.api_mode == \"chat_completions\" and self._is_direct_openai_url():\n+        # GPT-5.x models require the Responses API path — they are rejected\n+        # on /v1/chat/completions by both OpenAI and OpenRouter.  Also\n+        # auto-upgrade for direct OpenAI URLs (api.openai.com) since all\n+        # newer tool-calling models prefer Responses there.\n+        if self.api_mode == \"chat_completions\" and (\n+            self._is_direct_openai_url()\n+            or self._model_requires_responses_api(self.model)\n+        ):\n             self.api_mode = \"codex_responses\"\n \n         # Pre-warm OpenRouter model metadata cache in a background thread.","@@ -1702,6 +1706,21 @@ def _is_openrouter_url(self) -> bool:\n         \"\"\"Return True when the base URL targets OpenRouter.\"\"\"\n         return \"openrouter\" in self._base_url_lower\n \n+    @staticmethod\n+    def _model_requires_responses_api(model: str) -> bool:\n+        \"\"\"Return True for models that require the Responses API path.\n+\n+        GPT-5.x models are rejected on /v1/chat/completions by both\n+        OpenAI and OpenRouter (error: ``unsupported_api_for_model``).\n+        Detect these so the correct api_mode is set regardless of\n+        which provider is serving the model.\n+        \"\"\"\n+        m = model.lower()\n+        # Strip vendor prefix (e.g. \"openai/gpt-5.4\" → \"gpt-5.4\")\n+        if \"/\" in m:\n+            m = m.rsplit(\"/\", 1)[-1]\n+        return m.startswith(\"gpt-5\")\n+\n     def _max_tokens_param(self, value: int) -> dict:\n         \"\"\"Return the correct max tokens kwarg for the current provider.\n         ","@@ -5251,7 +5270,7 @@ def _try_activate_fallback(self) -> bool:\n             except Exception:\n                 pass\n \n-            # Determine api_mode from provider / base URL\n+            # Determine api_mode from provider / base URL / model\n             fb_api_mode = \"chat_completions\"\n             fb_base_url = str(fb_client.base_url)\n             if fb_provider == \"openai-codex\":","@@ -5260,6 +5279,10 @@ def _try_activate_fallback(self) -> bool:\n                 fb_api_mode = \"anthropic_messages\"\n             elif self._is_direct_openai_url(fb_base_url):\n                 fb_api_mode = \"codex_responses\"\n+            elif self._model_requires_responses_api(fb_model):\n+                # GPT-5.x models need Responses API on every provider\n+                # (OpenRouter, Copilot, direct OpenAI, etc.)\n+                fb_api_mode = \"codex_responses\"\n \n             old_model = self.model\n             self.model = fb_model","@@ -5348,8 +5371,8 @@ def _restore_primary_runtime(self) -> bool:\n         to the fallback provider for every subsequent turn.  Calling this at\n         the top of ``run_conversation()`` makes fallback turn-scoped.\n \n-        The gateway creates a fresh agent per message so this is a no-op\n-        there (``_fallback_activated`` is always False at turn start).\n+        The gateway caches agents across messages (``_agent_cache`` in\n+        ``gateway/run.py``), so this restoration IS needed there too.\n         \"\"\"\n         if not self._fallback_activated:\n             return False"]},{"file":"tests/gateway/test_matrix.py","hunks":["@@ -1041,20 +1041,28 @@ async def _sync_once(**kwargs):\n             call_count += 1\n             if call_count >= 1:\n                 adapter._closing = True\n-            return {\"rooms\": {\"join\": {\"!room:example.org\": {}}}}\n+            return {\"rooms\": {\"join\": {\"!room:example.org\": {}}}, \"next_batch\": \"s1234\"}\n \n         mock_crypto = MagicMock()\n         mock_crypto.share_keys = AsyncMock()\n \n+        mock_sync_store = MagicMock()\n+        mock_sync_store.get_next_batch = AsyncMock(return_value=None)\n+        mock_sync_store.put_next_batch = AsyncMock()\n+\n         fake_client = MagicMock()\n         fake_client.sync = AsyncMock(side_effect=_sync_once)\n         fake_client.crypto = mock_crypto\n+        fake_client.sync_store = mock_sync_store\n+        fake_client.handle_sync = MagicMock(return_value=[])\n         adapter._client = fake_client\n \n         await adapter._sync_loop()\n \n         fake_client.sync.assert_awaited_once()\n         mock_crypto.share_keys.assert_awaited_once()\n+        fake_client.handle_sync.assert_called_once()\n+        mock_sync_store.put_next_batch.assert_awaited_once_with(\"s1234\")\n \n \n class TestMatrixEncryptedSendFallback:"]},{"file":"tests/run_agent/test_run_agent_codex_responses.py","hunks":["@@ -222,6 +222,12 @@ def test_api_mode_normalizes_provider_case(monkeypatch):\n \n \n def test_api_mode_respects_explicit_openrouter_provider_over_codex_url(monkeypatch):\n+    \"\"\"GPT-5.x models need codex_responses even on OpenRouter.\n+\n+    OpenRouter rejects GPT-5 models on /v1/chat/completions with\n+    ``unsupported_api_for_model``.  The model-level check overrides\n+    the provider default.\n+    \"\"\"\n     _patch_agent_bootstrap(monkeypatch)\n     agent = run_agent.AIAgent(\n         model=\"gpt-5-codex\",","@@ -233,7 +239,7 @@ def test_api_mode_respects_explicit_openrouter_provider_over_codex_url(monkeypat\n         skip_context_files=True,\n         skip_memory=True,\n     )\n-    assert agent.api_mode == \"chat_completions\"\n+    assert agent.api_mode == \"codex_responses\"\n     assert agent.provider == \"openrouter\"\n \n "]},{"file":"website/docs/user-guide/messaging/feishu.md","hunks":["@@ -212,7 +212,24 @@ When users click buttons or interact with interactive cards sent by the bot, the\n \n Card action events are dispatched with `MessageType.COMMAND`, so they flow through the normal command processing pipeline.\n \n-To use this feature, enable the **Interactive Card** event in your Feishu app's event subscriptions (`card.action.trigger`).\n+This is also how **command approval** works — when the agent needs to run a dangerous command, it sends an interactive card with Allow Once / Session / Always / Deny buttons. The user clicks a button, and the card action callback delivers the approval decision back to the agent.\n+\n+### Required Feishu App Configuration\n+\n+Interactive cards require **three** configuration steps in the Feishu Developer Console. Missing any of them causes error **200340** when users click card buttons.\n+\n+1. **Subscribe to the card action event:**\n+   In **Event Subscriptions**, add `card.action.trigger` to your subscribed events.\n+\n+2. **Enable the Interactive Card capability:**\n+   In **App Features > Bot**, ensure the **Interactive Card** toggle is enabled. This tells Feishu that your app can receive card action callbacks.\n+\n+3. **Configure the Card Request URL (webhook mode only):**\n+   In **App Features > Bot > Message Card Request URL**, set the URL to the same endpoint as your event webhook (e.g. `https://your-server:8765/feishu/webhook`). In WebSocket mode this is handled automatically by the SDK.\n+\n+:::warning\n+Without all three steps, Feishu will successfully *send* interactive cards (sending only requires `im:message:send` permission), but clicking any button will return error 200340. The card appears to work — the error only surfaces when a user interacts with it.\n+:::\n \n ## Media Support\n ","@@ -412,6 +429,7 @@ WebSocket and per-group ACL settings are configured via `config.yaml` under `pla\n | Post messages show as plain text | The Feishu API rejected the post payload; this is normal fallback behavior. Check logs for details. |\n | Images/files not received by bot | Grant `im:message` and `im:resource` permission scopes to your Feishu app |\n | Bot identity not auto-detected | Grant `admin:app.info:readonly` scope, or set `FEISHU_BOT_OPEN_ID` / `FEISHU_BOT_NAME` manually |\n+| Error 200340 when clicking approval buttons | Enable **Interactive Card** capability and configure **Card Request URL** in the Feishu Developer Console. See [Required Feishu App Configuration](#required-feishu-app-configuration) above. |\n | `Webhook rate limit exceeded` | More than 120 requests/minute from the same IP. This is usually a misconfiguration or loop. |\n \n ## Toolset\n"]}]},{"kind":"pr","number":7981,"title":"fix(matrix): replace pickle crypto store with SQLite, fix E2EE decryption","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/matrix.py","gateway/session.py","pyproject.toml","tests/gateway/test_matrix.py","website/docs/user-guide/messaging/matrix.md"],"fix_hunks":[{"file":"gateway/platforms/matrix.py","hunks":["@@ -104,7 +104,7 @@ class _TrustStateStub:  # type: ignore[no-redef]\n # Uses get_hermes_home() so each profile gets its own Matrix store.\n from hermes_constants import get_hermes_dir as _get_hermes_dir\n _STORE_DIR = _get_hermes_dir(\"platforms/matrix/store\", \"matrix/store\")\n-_CRYPTO_PICKLE_PATH = _STORE_DIR / \"crypto_store.pickle\"\n+_CRYPTO_DB_PATH = _STORE_DIR / \"crypto.db\"\n \n # Grace period: ignore messages older than this many seconds before startup.\n _STARTUP_GRACE_SECONDS = 5","@@ -165,6 +165,33 @@ def check_matrix_requirements() -> bool:\n     return True\n \n \n+class _CryptoStateStore:\n+    \"\"\"Adapter that satisfies the mautrix crypto StateStore interface.\n+\n+    OlmMachine requires a StateStore with ``is_encrypted``,\n+    ``get_encryption_info``, and ``find_shared_rooms``.  The basic\n+    ``MemoryStateStore`` from ``mautrix.client`` doesn't implement these,\n+    so we provide simple implementations that consult the client's room\n+    state.\n+    \"\"\"\n+\n+    def __init__(self, client_state_store: Any, joined_rooms: set):\n+        self._ss = client_state_store\n+        self._joined_rooms = joined_rooms\n+\n+    async def is_encrypted(self, room_id: str) -> bool:\n+        return (await self.get_encryption_info(room_id)) is not None\n+\n+    async def get_encryption_info(self, room_id: str):\n+        if hasattr(self._ss, \"get_encryption_info\"):\n+            return await self._ss.get_encryption_info(room_id)\n+        return None\n+\n+    async def find_shared_rooms(self, user_id: str) -> list:\n+        # Return all joined rooms — simple but correct for a single-user bot.\n+        return list(self._joined_rooms)\n+\n+\n class MatrixAdapter(BasePlatformAdapter):\n     \"\"\"Gateway adapter for Matrix (any homeserver).\"\"\"\n ","@@ -199,6 +226,7 @@ def __init__(self, config: PlatformConfig):\n         )\n \n         self._client: Any = None  # mautrix.client.Client\n+        self._crypto_db: Any = None  # mautrix.util.async_db.Database\n         self._sync_task: Optional[asyncio.Task] = None\n         self._closing = False\n         self._startup_ts: float = 0.0","@@ -252,6 +280,92 @@ def _is_duplicate_event(self, event_id) -> bool:\n         self._processed_events_set.add(event_id)\n         return False\n \n+    # ------------------------------------------------------------------\n+    # E2EE helpers\n+    # ------------------------------------------------------------------\n+\n+    async def _verify_device_keys_on_server(self, client: Any, olm: Any) -> bool:\n+        \"\"\"Verify our device keys are on the homeserver after loading crypto state.\n+\n+        Returns True if keys are valid or were successfully re-uploaded.\n+        Returns False if verification fails (caller should refuse E2EE).\n+        \"\"\"\n+        try:\n+            resp = await client.query_keys({client.mxid: [client.device_id]})\n+        except Exception as exc:\n+            logger.error(\n+                \"Matrix: cannot verify device keys on server: %s — refusing E2EE\", exc,\n+            )\n+            return False\n+\n+        # query_keys returns typed objects (QueryKeysResponse, DeviceKeys\n+        # with KeyID keys).  Normalise to plain strings for comparison.\n+        device_keys_map = getattr(resp, \"device_keys\", {}) or {}\n+        our_user_devices = device_keys_map.get(str(client.mxid)) or {}\n+        our_keys = our_user_devices.get(str(client.device_id))\n+\n+        if not our_keys:\n+            logger.warning(\"Matrix: device keys missing from server — re-uploading\")\n+            olm.account.shared = False\n+            try:\n+                await olm.share_keys()\n+            except Exception as exc:\n+                logger.error(\"Matrix: failed to re-upload device keys: %s\", exc)\n+                return False\n+            return True\n+\n+        # DeviceKeys.keys is a dict[KeyID, str].  Iterate to find the\n+        # ed25519 key rather than constructing a KeyID for lookup.\n+        server_ed25519 = None\n+        keys_dict = getattr(our_keys, \"keys\", {}) or {}\n+        for key_id, key_value in keys_dict.items():\n+            if str(key_id).startswith(\"ed25519:\"):\n+                server_ed25519 = str(key_value)\n+                break\n+        local_ed25519 = olm.account.identity_keys.get(\"ed25519\")\n+\n+        if server_ed25519 != local_ed25519:\n+            if olm.account.shared:\n+                # Restored account from DB but server has different keys — corrupted state.\n+                logger.error(\n+                    \"Matrix: server has different identity keys for device %s — \"\n+                    \"local crypto state is stale. Delete %s and restart.\",\n+                    client.device_id,\n+                    _CRYPTO_DB_PATH,\n+                )\n+                return False\n+\n+            # Fresh account (never uploaded). Server has stale keys from a\n+            # previous installation. Try to delete the old device and re-upload.\n+            logger.warning(\n+                \"Matrix: server has stale keys for device %s — attempting re-upload\",\n+                client.device_id,\n+            )\n+            try:\n+                await client.api.request(\n+                    client.api.Method.DELETE\n+                    if hasattr(client.api, \"Method\")\n+                    else \"DELETE\",\n+                    f\"/_matrix/client/v3/devices/{client.device_id}\",\n+                )\n+                logger.info(\"Matrix: deleted stale device %s from server\", client.device_id)\n+            except Exception:\n+                # Device deletion often requires UIA or may simply not be\n+                # permitted — that's fine, share_keys will try to overwrite.\n+                pass\n+            try:\n+                await olm.share_keys()\n+            except Exception as exc:\n+                logger.error(\n+                    \"Matrix: cannot upload device keys for %s: %s. \"\n+                    \"Try generating a new access token to get a fresh device.\",\n+                    client.device_id,\n+                    exc,\n+                )\n+                return False\n+\n+        return True\n+\n     # ------------------------------------------------------------------\n     # Required overrides\n     # ------------------------------------------------------------------","@@ -350,54 +464,54 @@ async def connect(self) -> bool:\n                 return False\n             try:\n                 from mautrix.crypto import OlmMachine\n-                from mautrix.crypto.store import MemoryCryptoStore\n+                from mautrix.crypto.store.asyncpg import PgCryptoStore\n+                from mautrix.util.async_db import Database\n+\n+                _STORE_DIR.mkdir(parents=True, exist_ok=True)\n+\n+                # Remove legacy pickle file from pre-SQLite era.\n+                legacy_pickle = _STORE_DIR / \"crypto_store.pickle\"\n+                if legacy_pickle.exists():\n+                    logger.info(\"Matrix: removing legacy crypto_store.pickle (migrated to SQLite)\")\n+                    legacy_pickle.unlink()\n+\n+                # Open SQLite-backed crypto store.\n+                crypto_db = Database.create(\n+                    f\"sqlite:///{_CRYPTO_DB_PATH}\",\n+                    upgrade_table=PgCryptoStore.upgrade_table,\n+                )\n+                await crypto_db.start()\n+                self._crypto_db = crypto_db\n \n-                # account_id and pickle_key are required by mautrix ≥0.21.\n-                # Use the Matrix user ID as account_id for stable identity.\n-                # pickle_key secures in-memory serialisation; derive from\n-                # the same user_id:device_id pair used for the on-disk HMAC.\n                 _acct_id = self._user_id or \"hermes\"\n-                _pickle_key = f\"{_acct_id}:{self._device_id}\"\n-                crypto_store = MemoryCryptoStore(\n+                _pickle_key = f\"{_acct_id}:{self._device_id or 'default'}\"\n+                crypto_store = PgCryptoStore(\n                     account_id=_acct_id,\n                     pickle_key=_pickle_key,\n+                    db=crypto_db,\n                 )\n+                await crypto_store.open()\n \n-                # Restore persisted crypto state from a previous run.\n-                # Uses HMAC to verify integrity before unpickling.\n-                pickle_path = _CRYPTO_PICKLE_PATH\n-                if pickle_path.exists():\n-                    try:\n-                        import hashlib, hmac, pickle\n-                        raw = pickle_path.read_bytes()\n-                        # Format: 32-byte HMAC-SHA256 signature + pickle data.\n-                        if len(raw) > 32:\n-                            sig, payload = raw[:32], raw[32:]\n-                            # Key is derived from the device_id + user_id (stable per install).\n-                            hmac_key = f\"{self._user_id}:{self._device_id}\".encode()\n-                            expected = hmac.new(hmac_key, payload, hashlib.sha256).digest()\n-                            if hmac.compare_digest(sig, expected):\n-                                saved = pickle.loads(payload)  # noqa: S301\n-                                if isinstance(saved, MemoryCryptoStore):\n-                                    crypto_store = saved\n-                                    logger.info(\"Matrix: restored E2EE crypto store from %s\", pickle_path)\n-                            else:\n-                                logger.warning(\"Matrix: crypto store HMAC mismatch — ignoring stale/tampered file\")\n-                    except Exception as exc:\n-                        logger.warning(\"Matrix: could not restore crypto store: %s\", exc)\n-\n-                olm = OlmMachine(client, crypto_store, state_store)\n+                crypto_state = _CryptoStateStore(state_store, self._joined_rooms)\n+                olm = OlmMachine(client, crypto_store, crypto_state)\n \n-                # Set trust policy: accept unverified devices so senders\n-                # share Megolm session keys with us automatically.\n+                # Accept unverified devices so senders share Megolm\n+                # session keys with us automatically.\n                 olm.share_keys_min_trust = TrustState.UNVERIFIED\n                 olm.send_keys_min_trust = TrustState.UNVERIFIED\n \n                 await olm.load()\n+\n+                # Verify our device keys are still on the homeserver.\n+                if not await self._verify_device_keys_on_server(client, olm):\n+                    await crypto_db.stop()\n+                    await api.session.close()\n+                    return False\n+\n                 client.crypto = olm\n                 logger.info(\n                     \"Matrix: E2EE enabled (store: %s%s)\",\n-                    str(_STORE_DIR),\n+                    str(_CRYPTO_DB_PATH),\n                     f\", device_id={client.device_id}\" if client.device_id else \"\",\n                 )\n             except Exception as exc:","@@ -438,6 +552,15 @@ async def connect(self) -> bool:\n                 )\n                 # Build DM room cache from m.direct account data.\n                 await self._refresh_dm_cache()\n+\n+                # Dispatch events from the initial sync so the OlmMachine\n+                # receives to-device key shares queued while we were offline.\n+                try:\n+                    tasks = client.handle_sync(sync_data)\n+                    if tasks:\n+                        await asyncio.gather(*tasks)\n+                except Exception as exc:\n+                    logger.warning(\"Matrix: initial sync event dispatch error: %s\", exc)\n             else:\n                 logger.warning(\"Matrix: initial sync returned unexpected type %s\", type(sync_data).__name__)\n         except Exception as exc:","@@ -466,21 +589,12 @@ async def disconnect(self) -> None:\n             except (asyncio.CancelledError, Exception):\n                 pass\n \n-        # Persist E2EE crypto store before closing so the next restart\n-        # can decrypt events using sessions from this run.\n-        if self._client and self._encryption and getattr(self._client, \"crypto\", None):\n+        # Close the SQLite crypto store database.\n+        if hasattr(self, \"_crypto_db\") and self._crypto_db:\n             try:\n-                import hashlib, hmac, pickle\n-                crypto_store = self._client.crypto.crypto_store\n-                _STORE_DIR.mkdir(parents=True, exist_ok=True)\n-                pickle_path = _CRYPTO_PICKLE_PATH\n-                payload = pickle.dumps(crypto_store)\n-                hmac_key = f\"{self._user_id}:{self._device_id}\".encode()\n-                sig = hmac.new(hmac_key, payload, hashlib.sha256).digest()\n-                pickle_path.write_bytes(sig + payload)\n-                logger.info(\"Matrix: persisted E2EE crypto store to %s\", pickle_path)\n+                await self._crypto_db.stop()\n             except Exception as exc:\n-                logger.debug(\"Matrix: could not persist crypto store on disconnect: %s\", exc)\n+                logger.debug(\"Matrix: could not close crypto DB on disconnect: %s\", exc)\n \n         if self._client:\n             try:","@@ -853,13 +967,6 @@ async def _sync_loop(self) -> None:\n                     except Exception as exc:\n                         logger.warning(\"Matrix: sync event dispatch error: %s\", exc)\n \n-                # Share keys periodically if E2EE is enabled.\n-                if self._encryption and getattr(client, \"crypto\", None):\n-                    try:\n-                        await client.crypto.share_keys()\n-                    except Exception as exc:\n-                        logger.warning(\"Matrix: E2EE key share failed: %s\", exc)\n-\n                 # Retry any buffered undecrypted events.\n                 if self._pending_megolm:\n                     await self._retry_pending_decryptions()"]},{"file":"gateway/session.py","hunks":["@@ -807,9 +807,9 @@ def suspend_recently_active(self, max_age_seconds: int = 120) -> int:\n         to avoid resetting long-idle sessions that are harmless to resume.\n         Returns the number of sessions that were suspended.\n         \"\"\"\n-        import time as _time\n+        from datetime import timedelta\n \n-        cutoff = _time.time() - max_age_seconds\n+        cutoff = _now() - timedelta(seconds=max_age_seconds)\n         count = 0\n         with self._lock:\n             self._ensure_loaded_locked()"]},{"file":"pyproject.toml","hunks":["@@ -43,7 +43,7 @@ dev = [\"debugpy>=1.8.0,<2\", \"pytest>=9.0.2,<10\", \"pytest-asyncio>=1.3.0,<2\", \"py\n messaging = [\"python-telegram-bot[webhooks]>=22.6,<23\", \"discord.py[voice]>=2.7.1,<3\", \"aiohttp>=3.13.3,<4\", \"slack-bolt>=1.18.0,<2\", \"slack-sdk>=3.27.0,<4\"]\n cron = [\"croniter>=6.0.0,<7\"]\n slack = [\"slack-bolt>=1.18.0,<2\", \"slack-sdk>=3.27.0,<4\"]\n-matrix = [\"mautrix[encryption]>=0.20,<1\", \"Markdown>=3.6,<4\"]\n+matrix = [\"mautrix[encryption]>=0.20,<1\", \"Markdown>=3.6,<4\", \"aiosqlite>=0.20\", \"asyncpg>=0.29\"]\n cli = [\"simple-term-menu>=1.0,<2\"]\n tts-premium = [\"elevenlabs>=1.0,<2\"]\n voice = ["]},{"file":"tests/gateway/test_matrix.py","hunks":["@@ -157,12 +157,44 @@ async def decrypt_megolm_event(self, event):\n     mautrix_crypto_store = types.ModuleType(\"mautrix.crypto.store\")\n \n     class MemoryCryptoStore:\n-        def __init__(self, account_id=\"\", pickle_key=\"\"):\n+        def __init__(self, account_id=\"\", pickle_key=\"\"):  # noqa: S301\n             self.account_id = account_id\n             self.pickle_key = pickle_key\n \n     mautrix_crypto_store.MemoryCryptoStore = MemoryCryptoStore\n \n+    # --- mautrix.crypto.store.asyncpg ---\n+    mautrix_crypto_store_asyncpg = types.ModuleType(\"mautrix.crypto.store.asyncpg\")\n+\n+    class PgCryptoStore:\n+        upgrade_table = MagicMock()\n+\n+        def __init__(self, account_id=\"\", pickle_key=\"\", db=None):  # noqa: S301\n+            self.account_id = account_id\n+            self.pickle_key = pickle_key\n+            self.db = db\n+\n+        async def open(self):\n+            pass\n+\n+    mautrix_crypto_store_asyncpg.PgCryptoStore = PgCryptoStore\n+\n+    # --- mautrix.util ---\n+    mautrix_util = types.ModuleType(\"mautrix.util\")\n+\n+    # --- mautrix.util.async_db ---\n+    mautrix_util_async_db = types.ModuleType(\"mautrix.util.async_db\")\n+\n+    class Database:\n+        @classmethod\n+        def create(cls, url, upgrade_table=None):\n+            db = MagicMock()\n+            db.start = AsyncMock()\n+            db.stop = AsyncMock()\n+            return db\n+\n+    mautrix_util_async_db.Database = Database\n+\n     return {\n         \"mautrix\": mautrix,\n         \"mautrix.api\": mautrix_api,","@@ -171,6 +203,9 @@ def __init__(self, account_id=\"\", pickle_key=\"\"):\n         \"mautrix.client.state_store\": mautrix_client_state_store,\n         \"mautrix.crypto\": mautrix_crypto,\n         \"mautrix.crypto.store\": mautrix_crypto_store,\n+        \"mautrix.crypto.store.asyncpg\": mautrix_crypto_store_asyncpg,\n+        \"mautrix.util\": mautrix_util,\n+        \"mautrix.util.async_db\": mautrix_util_async_db,\n     }\n \n ","@@ -740,6 +775,12 @@ def __init__(self, user_id, device_id):\n         mock_client.whoami = AsyncMock(return_value=FakeWhoamiResponse(\"@bot:example.org\", \"DEV123\"))\n         mock_client.sync = AsyncMock(return_value={\"rooms\": {\"join\": {\"!room:server\": {}}}})\n         mock_client.add_event_handler = MagicMock()\n+        mock_client.handle_sync = MagicMock(return_value=[])\n+        mock_client.query_keys = AsyncMock(return_value={\n+            \"device_keys\": {\"@bot:example.org\": {\"DEV123\": {\n+                \"keys\": {\"ed25519:DEV123\": \"fake_ed25519_key\"},\n+            }}},\n+        })\n         mock_client.api = MagicMock()\n         mock_client.api.token = \"[redacted:secret-assignment]\"\n         mock_client.api.session = MagicMock()","@@ -751,6 +792,8 @@ def __init__(self, user_id, device_id):\n         mock_olm.share_keys = AsyncMock()\n         mock_olm.share_keys_min_trust = None\n         mock_olm.send_keys_min_trust = None\n+        mock_olm.account = MagicMock()\n+        mock_olm.account.identity_keys = {\"ed25519\": \"fake_ed25519_key\"}\n \n         # Patch Client constructor to return our mock\n         fake_mautrix_mods[\"mautrix.client\"].Client = MagicMock(return_value=mock_client)","@@ -924,6 +967,12 @@ async def test_connect_uses_configured_device_id_over_whoami(self):\n         mock_client.whoami = AsyncMock(return_value=MagicMock(user_id=\"@bot:example.org\", device_id=\"WHOAMI_DEV\"))\n         mock_client.sync = AsyncMock(return_value={\"rooms\": {\"join\": {\"!room:server\": {}}}})\n         mock_client.add_event_handler = MagicMock()\n+        mock_client.handle_sync = MagicMock(return_value=[])\n+        mock_client.query_keys = AsyncMock(return_value={\n+            \"device_keys\": {\"@bot:example.org\": {\"MY_STABLE_DEVICE\": {\n+                \"keys\": {\"ed25519:MY_STABLE_DEVICE\": \"fake_ed25519_key\"},\n+            }}},\n+        })\n         mock_client.api = MagicMock()\n         mock_client.api.token = \"[redacted:secret-assignment]\"\n         mock_client.api.session = MagicMock()","@@ -934,6 +983,8 @@ async def test_connect_uses_configured_device_id_over_whoami(self):\n         mock_olm.share_keys = AsyncMock()\n         mock_olm.share_keys_min_trust = None\n         mock_olm.send_keys_min_trust = None\n+        mock_olm.account = MagicMock()\n+        mock_olm.account.identity_keys = {\"ed25519\": \"fake_ed25519_key\"}\n \n         fake_mautrix_mods[\"mautrix.client\"].Client = MagicMock(return_value=mock_client)\n         fake_mautrix_mods[\"mautrix.crypto\"].OlmMachine = MagicMock(return_value=mock_olm)","@@ -1030,8 +1081,8 @@ def test_device_id_not_set_when_env_empty(self, monkeypatch):\n \n class TestMatrixSyncLoop:\n     @pytest.mark.asyncio\n-    async def test_sync_loop_shares_keys_when_encryption_enabled(self):\n-        \"\"\"_sync_loop should call crypto.share_keys() after each sync.\"\"\"\n+    async def test_sync_loop_dispatches_events_and_stores_token(self):\n+        \"\"\"_sync_loop should call handle_sync() and persist next_batch.\"\"\"\n         adapter = _make_adapter()\n         adapter._encryption = True\n         adapter._closing = False","@@ -1046,7 +1097,6 @@ async def _sync_once(**kwargs):\n             return {\"rooms\": {\"join\": {\"!room:example.org\": {}}}, \"next_batch\": \"s1234\"}\n \n         mock_crypto = MagicMock()\n-        mock_crypto.share_keys = AsyncMock()\n \n         mock_sync_store = MagicMock()\n         mock_sync_store.get_next_batch = AsyncMock(return_value=None)","@@ -1062,7 +1112,6 @@ async def _sync_once(**kwargs):\n         await adapter._sync_loop()\n \n         fake_client.sync.assert_awaited_once()\n-        mock_crypto.share_keys.assert_awaited_once()\n         fake_client.handle_sync.assert_called_once()\n         mock_sync_store.put_next_batch.assert_awaited_once_with(\"s1234\")\n ","@@ -1248,6 +1297,12 @@ async def test_connect_registers_encrypted_event_handler_when_encryption_on(self\n         mock_client.whoami = AsyncMock(return_value=MagicMock(user_id=\"@bot:example.org\", device_id=\"DEV123\"))\n         mock_client.sync = AsyncMock(return_value={\"rooms\": {\"join\": {\"!room:server\": {}}}})\n         mock_client.add_event_handler = MagicMock()\n+        mock_client.handle_sync = MagicMock(return_value=[])\n+        mock_client.query_keys = AsyncMock(return_value={\n+            \"device_keys\": {\"@bot:example.org\": {\"DEV123\": {\n+                \"keys\": {\"ed25519:DEV123\": \"fake_ed25519_key\"},\n+            }}},\n+        })\n         mock_client.api = MagicMock()\n         mock_client.api.token = \"[redacted:secret-assignment]\"\n         mock_client.api.session = MagicMock()","@@ -1258,6 +1313,8 @@ async def test_connect_registers_encrypted_event_handler_when_encryption_on(self\n         mock_olm.share_keys = AsyncMock()\n         mock_olm.share_keys_min_trust = None\n         mock_olm.send_keys_min_trust = None\n+        mock_olm.account = MagicMock()\n+        mock_olm.account.identity_keys = {\"ed25519\": \"fake_ed25519_key\"}\n \n         fake_mautrix_mods[\"mautrix.client\"].Client = MagicMock(return_value=mock_client)\n         fake_mautrix_mods[\"mautrix.crypto\"].OlmMachine = MagicMock(return_value=mock_olm)"]},{"file":"website/docs/user-guide/messaging/matrix.md","hunks":["@@ -344,9 +344,79 @@ pip install 'hermes-agent[matrix]'\n **Fix**:\n 1. Verify `libolm` is installed on your system (see the E2EE section above).\n 2. Make sure `MATRIX_ENCRYPTION=true` is set in your `.env`.\n-3. In your Matrix client (Element), go to the bot's profile → **Sessions** → verify/trust the bot's device.\n+3. In your Matrix client (Element), go to the bot's profile -> Sessions -> verify/trust the bot's device.\n 4. If the bot just joined an encrypted room, it can only decrypt messages sent *after* it joined. Older messages are inaccessible.\n \n+### Upgrading from a previous version with E2EE\n+\n+If you previously used Hermes with `MATRIX_ENCRYPTION=true` and are upgrading to\n+a version that uses the new SQLite-based crypto store, the bot's encryption\n+identity has changed. Your Matrix client (Element) may cache the old device keys\n+and refuse to share encryption sessions with the bot.\n+\n+**Symptoms**: The bot connects and shows \"E2EE enabled\" in the logs, but all\n+messages show \"could not decrypt event\" and the bot never responds.\n+\n+**What's happening**: The old encryption state (from the previous `matrix-nio` or\n+serialization-based `mautrix` backend) is incompatible with the new SQLite crypto\n+store. The bot creates a fresh encryption identity, but your Matrix client still\n+has the old keys cached and won't share the room's encryption session with a\n+device whose keys changed. This is a Matrix security feature -- clients treat\n+changed identity keys for the same device as suspicious.\n+\n+**Fix** (one-time migration):\n+\n+1. **Generate a new access token** to get a fresh device ID. The simplest way:\n+\n+   ```bash\n+   curl -X POST https://your-server/_matrix/client/v3/login \\\n+     -H \"Content-Type: application/json\" \\\n+     -d '{\n+       \"type\": \"m.login.password\",\n+       \"identifier\": {\"type\": \"m.id.user\", \"user\": \"@hermes:your-server.org\"},\n+       \"password\": \"your-password\",\n+       \"initial_device_display_name\": \"Hermes Agent\"\n+     }'\n+   ```\n+\n+   Copy the new `access_token` and update `MATRIX_ACCESS_TOKEN` in `~/.hermes/.env`.\n+\n+2. **Delete old encryption state**:\n+\n+   ```bash\n+   rm -f ~/.hermes/platforms/matrix/store/crypto.db\n+   rm -f ~/.hermes/platforms/matrix/store/crypto_store.*\n+   ```\n+\n+3. **Force your Matrix client to rotate the encryption session**. In Element,\n+   open the DM room with the bot and type `/discardsession`. This forces Element\n+   to create a new encryption session and share it with the bot's new device.\n+\n+4. **Restart the gateway**:\n+\n+   ```bash\n+   hermes gateway run\n+   ```\n+\n+5. **Send a new message**. The bot should decrypt and respond normally.\n+\n+:::note\n+After migration, messages sent *before* the upgrade cannot be decrypted -- the old\n+encryption keys are gone. This only affects the transition; new messages work\n+normally.\n+:::\n+\n+:::tip\n+**New installations are not affected.** This migration is only needed if you had\n+a working E2EE setup with a previous version of Hermes and are upgrading.\n+\n+**Why a new access token?** Each Matrix access token is bound to a specific device\n+ID. Reusing the same device ID with new encryption keys causes other Matrix\n+clients to distrust the device (they see changed identity keys as a potential\n+security breach). A new access token gets a new device ID with no stale key\n+history, so other clients trust it immediately.\n+:::\n+\n ### Sync issues / bot falls behind\n \n **Cause**: Long-running tool executions can delay the sync loop, or the homeserver is slow.\n"]}]},{"kind":"pr","number":8026,"title":"fix(gateway): use datetime cutoff in suspend_recently_active","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"50d86b3c7","number":0},{"kind":"commit","value":"50d86b3c71f274ed6ff489cdb68a7393377f3d1c","number":0},{"kind":"pr","value":"#7981","number":7981},{"kind":"pr","value":"#7966","number":7966},{"kind":"pr","value":"#8002","number":8002}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":8100,"title":"fix(gateway): use datetime cutoff in suspend_recently_active","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#7966","number":7966}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":8215,"title":"fix(gateway): fix Matrix E2EE silent failures after mautrix migration","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":10135,"title":"fix(matrix): register MembershipEventDispatcher to fix auto-join on DM invites","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d38b73fa5","number":0},{"kind":"commit","value":"d38b73fa57b19b11efaeaf6256c05cc2d4f7387f","number":0},{"kind":"pr","value":"#10860","number":10860},{"kind":"pr","value":"#10860","number":10860},{"kind":"pr","value":"#10135","number":10135},{"kind":"pr","value":"#10094","number":10094}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":10732,"title":"fix(matrix): register MembershipEventDispatcher for invite auto-join","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d38b73fa5","number":0},{"kind":"commit","value":"d38b73fa5","number":0},{"kind":"pr","value":"#10860","number":10860},{"kind":"pr","value":"#10860","number":10860},{"kind":"pr","value":"#10732","number":10732}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":10803,"title":"fix(matrix): register MembershipEventDispatcher for invite auto-join","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d38b73fa5","number":0},{"kind":"commit","value":"d38b73fa5","number":0},{"kind":"pr","value":"#10860","number":10860},{"kind":"pr","value":"#10860","number":10860},{"kind":"pr","value":"#10725","number":10725}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":10860,"title":"fix(matrix): E2EE and migration bugfixes","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","gateway/platforms/matrix.py","gateway/run.py","gateway/stream_consumer.py","tests/gateway/test_matrix.py","tests/gateway/test_matrix_mention.py","tests/gateway/test_stream_consumer.py","website/docs/user-guide/messaging/matrix.md"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -1260,7 +1260,7 @@ def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]:\n                 path = path[1:-1].strip()\n             path = path.lstrip(\"`\\\"'\").rstrip(\"`\\\"',.;:)}]\")\n             if path:\n-                media.append((path, has_voice_tag))\n+                media.append((os.path.expanduser(path), has_voice_tag))\n \n         # Remove MEDIA tags from content (including surrounding quote/backtick wrappers)\n         if media:"]},{"file":"gateway/platforms/matrix.py","hunks":["@@ -30,11 +30,10 @@\n import os\n import re\n import time\n+from html import escape as _html_escape\n from pathlib import Path\n from typing import Any, Dict, Optional, Set\n \n-from html import escape as _html_escape\n-\n try:\n     from mautrix.types import (\n         ContentURI,","@@ -60,28 +59,33 @@ class _EventTypeStub:  # type: ignore[no-redef]\n         REACTION = \"m.reaction\"\n         ROOM_ENCRYPTED = \"m.room.encrypted\"\n         ROOM_NAME = \"m.room.name\"\n+\n     EventType = _EventTypeStub  # type: ignore[misc,assignment]\n \n     class _PaginationDirectionStub:  # type: ignore[no-redef]\n         BACKWARD = \"b\"\n         FORWARD = \"f\"\n+\n     PaginationDirection = _PaginationDirectionStub  # type: ignore[misc,assignment]\n \n     class _PresenceStateStub:  # type: ignore[no-redef]\n         ONLINE = \"online\"\n         OFFLINE = \"offline\"\n         UNAVAILABLE = \"unavailable\"\n+\n     PresenceState = _PresenceStateStub  # type: ignore[misc,assignment]\n \n     class _RoomCreatePresetStub:  # type: ignore[no-redef]\n         PRIVATE = \"private_chat\"\n         PUBLIC = \"public_chat\"\n         TRUSTED_PRIVATE = \"trusted_private_chat\"\n+\n     RoomCreatePreset = _RoomCreatePresetStub  # type: ignore[misc,assignment]\n \n     class _TrustStateStub:  # type: ignore[no-redef]\n         UNVERIFIED = 0\n         VERIFIED = 1\n+\n     TrustState = _TrustStateStub  # type: ignore[misc,assignment]\n \n from gateway.config import Platform, PlatformConfig","@@ -103,20 +107,16 @@ class _TrustStateStub:  # type: ignore[no-redef]\n # Store directory for E2EE keys and sync state.\n # Uses get_hermes_home() so each profile gets its own Matrix store.\n from hermes_constants import get_hermes_dir as _get_hermes_dir\n+\n _STORE_DIR = _get_hermes_dir(\"platforms/matrix/store\", \"matrix/store\")\n _CRYPTO_DB_PATH = _STORE_DIR / \"crypto.db\"\n \n # Grace period: ignore messages older than this many seconds before startup.\n _STARTUP_GRACE_SECONDS = 5\n \n-# Pending undecrypted events: cap and TTL for retry buffer.\n-_MAX_PENDING_EVENTS = 100\n-_PENDING_EVENT_TTL = 300  # seconds — stop retrying after 5 min\n-\n \n _E2EE_INSTALL_HINT = (\n-    \"Install with: pip install 'mautrix[encryption]'  \"\n-    \"(requires libolm C library)\"\n+    \"Install with: pip install 'mautrix[encryption]'  (requires libolm C library)\"\n )\n \n ","@@ -124,6 +124,7 @@ def _check_e2ee_deps() -> bool:\n     \"\"\"Return True if mautrix E2EE dependencies (python-olm) are available.\"\"\"\n     try:\n         from mautrix.crypto import OlmMachine  # noqa: F401\n+\n         return True\n     except (ImportError, AttributeError):\n         return False","@@ -145,14 +146,17 @@ def check_matrix_requirements() -> bool:\n         import mautrix  # noqa: F401\n     except ImportError:\n         logger.warning(\n-            \"Matrix: mautrix not installed. \"\n-            \"Run: pip install 'mautrix[encryption]'\"\n+            \"Matrix: mautrix not installed. Run: pip install 'mautrix[encryption]'\"\n         )\n         return False\n \n     # If encryption is requested, verify E2EE deps are available at startup\n     # rather than silently degrading to plaintext-only at connect time.\n-    encryption_requested = os.getenv(\"MATRIX_ENCRYPTION\", \"\").lower() in (\"true\", \"1\", \"yes\")\n+    encryption_requested = os.getenv(\"MATRIX_ENCRYPTION\", \"\").lower() in (\n+        \"true\",\n+        \"1\",\n+        \"yes\",\n+    )\n     if encryption_requested and not _check_e2ee_deps():\n         logger.error(\n             \"Matrix: MATRIX_ENCRYPTION=true but E2EE dependencies are missing. %s. \"","@@ -204,25 +208,21 @@ def __init__(self, config: PlatformConfig):\n         super().__init__(config, Platform.MATRIX)\n \n         self._homeserver: str = (\n-            config.extra.get(\"homeserver\", \"\")\n-            or os.getenv(\"MATRIX_HOMESERVER\", \"\")\n+            config.extra.get(\"homeserver\", \"\") or os.getenv(\"MATRIX_HOMESERVER\", \"\")\n         ).rstrip(\"/\")\n         self._access_token: str = config.token or os.getenv(\"MATRIX_ACCESS_TOKEN\", \"\")\n-        self._user_id: str = (\n-            config.extra.get(\"user_id\", \"\")\n-            or os.getenv(\"MATRIX_USER_ID\", \"\")\n+        self._user_id: str = config.extra.get(\"user_id\", \"\") or os.getenv(\n+            \"MATRIX_USER_ID\", \"\"\n         )\n-        self._password: str = (\n-            config.extra.get(\"password\", \"\")\n-            or os.getenv(\"MATRIX_PASSWORD\", \"\")\n+        self._password: str = config.extra.get(\"password\", \"\") or os.getenv(\n+            \"MATRIX_PASSWORD\", \"\"\n         )\n         self._encryption: bool = config.extra.get(\n             \"encryption\",\n             os.getenv(\"MATRIX_ENCRYPTION\", \"\").lower() in (\"true\", \"1\", \"yes\"),\n         )\n-        self._device_id: str = (\n-            config.extra.get(\"device_id\", \"\")\n-            or os.getenv(\"MATRIX_DEVICE_ID\", \"\")\n+        self._device_id: str = config.extra.get(\"device_id\", \"\") or os.getenv(\n+            \"MATRIX_DEVICE_ID\", \"\"\n         )\n \n         self._client: Any = None  # mautrix.client.Client","@@ -237,22 +237,32 @@ def __init__(self, config: PlatformConfig):\n         self._joined_rooms: Set[str] = set()\n         # Event deduplication (bounded deque keeps newest entries)\n         from collections import deque\n+\n         self._processed_events: deque = deque(maxlen=1000)\n         self._processed_events_set: set = set()\n \n         # Buffer for undecrypted events pending key receipt.\n         # Each entry: (room_id, event, timestamp)\n-        self._pending_megolm: list = []\n \n         # Thread participation tracking (for require_mention bypass)\n         self._threads = ThreadParticipationTracker(\"matrix\")\n \n         # Mention/thread gating — parsed once from env vars.\n-        self._require_mention: bool = os.getenv(\"MATRIX_REQUIRE_MENTION\", \"true\").lower() not in (\"false\", \"0\", \"no\")\n+        self._require_mention: bool = os.getenv(\n+            \"MATRIX_REQUIRE_MENTION\", \"true\"\n+        ).lower() not in (\"false\", \"0\", \"no\")\n         free_rooms_raw = os.getenv(\"MATRIX_FREE_RESPONSE_ROOMS\", \"\")\n-        self._free_rooms: Set[str] = {r.strip() for r in free_rooms_raw.split(\",\") if r.strip()}\n-        self._auto_thread: bool = os.getenv(\"MATRIX_AUTO_THREAD\", \"true\").lower() in (\"true\", \"1\", \"yes\")\n-        self._dm_mention_threads: bool = os.getenv(\"MATRIX_DM_MENTION_THREADS\", \"false\").lower() in (\"true\", \"1\", \"yes\")\n+        self._free_rooms: Set[str] = {\n+            r.strip() for r in free_rooms_raw.split(\",\") if r.strip()\n+        }\n+        self._auto_thread: bool = os.getenv(\"MATRIX_AUTO_THREAD\", \"true\").lower() in (\n+            \"true\",\n+            \"1\",\n+            \"yes\",\n+        )\n+        self._dm_mention_threads: bool = os.getenv(\n+            \"MATRIX_DM_MENTION_THREADS\", \"false\"\n+        ).lower() in (\"true\", \"1\", \"yes\")\n \n         # Reactions: configurable via MATRIX_REACTIONS (default: true).\n         self._reactions_enabled: bool = os.getenv(","@@ -262,8 +272,12 @@ def __init__(self, config: PlatformConfig):\n \n         # Text batching: merge rapid successive messages (Telegram-style).\n         # Matrix clients split long messages around 4000 chars.\n-        self._text_batch_delay_seconds = float(os.getenv(\"HERMES_MATRIX_TEXT_BATCH_DELAY_SECONDS\", \"0.6\"))\n-        self._text_batch_split_delay_seconds = float(os.getenv(\"HERMES_MATRIX_TEXT_BATCH_SPLIT_DELAY_SECONDS\", \"2.0\"))\n+        self._text_batch_delay_seconds = float(\n+            os.getenv(\"HERMES_MATRIX_TEXT_BATCH_DELAY_SECONDS\", \"0.6\")\n+        )\n+        self._text_batch_split_delay_seconds = float(\n+            os.getenv(\"HERMES_MATRIX_TEXT_BATCH_SPLIT_DELAY_SECONDS\", \"2.0\")\n+        )\n         self._pending_text_batches: Dict[str, MessageEvent] = {}\n         self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}\n ","@@ -284,6 +298,38 @@ def _is_duplicate_event(self, event_id) -> bool:\n     # E2EE helpers\n     # ------------------------------------------------------------------\n \n+    @staticmethod\n+    def _extract_server_ed25519(device_keys_obj: Any) -> Optional[str]:\n+        \"\"\"Extract the ed25519 identity key from a DeviceKeys object.\"\"\"\n+        for kid, kval in (getattr(device_keys_obj, \"keys\", {}) or {}).items():\n+            if str(kid).startswith(\"ed25519:\"):\n+                return str(kval)\n+        return None\n+\n+    async def _reverify_keys_after_upload(\n+        self, client: Any, local_ed25519: str\n+    ) -> bool:\n+        \"\"\"Re-query the server after share_keys() and verify our ed25519 key matches.\"\"\"\n+        try:\n+            resp = await client.query_keys({client.mxid: [client.device_id]})\n+            dk = getattr(resp, \"device_keys\", {}) or {}\n+            ud = dk.get(str(client.mxid)) or {}\n+            dev = ud.get(str(client.device_id))\n+            if dev:\n+                server_ed = self._extract_server_ed25519(dev)\n+                if server_ed != local_ed25519:\n+                    logger.error(\n+                        \"Matrix: device %s has immutable identity keys that \"\n+                        \"don't match this installation. Generate a new access \"\n+                        \"token with a fresh device.\",\n+                        client.device_id,\n+                    )\n+                    return False\n+        except Exception as exc:\n+            logger.error(\"Matrix: post-upload key verification failed: %s\", exc)\n+            return False\n+        return True\n+\n     async def _verify_device_keys_on_server(self, client: Any, olm: Any) -> bool:\n         \"\"\"Verify our device keys are on the homeserver after loading crypto state.\n ","@@ -294,15 +340,15 @@ async def _verify_device_keys_on_server(self, client: Any, olm: Any) -> bool:\n             resp = await client.query_keys({client.mxid: [client.device_id]})\n         except Exception as exc:\n             logger.error(\n-                \"Matrix: cannot verify device keys on server: %s — refusing E2EE\", exc,\n+                \"Matrix: cannot verify device keys on server: %s — refusing E2EE\",\n+                exc,\n             )\n             return False\n \n-        # query_keys returns typed objects (QueryKeysResponse, DeviceKeys\n-        # with KeyID keys).  Normalise to plain strings for comparison.\n         device_keys_map = getattr(resp, \"device_keys\", {}) or {}\n         our_user_devices = device_keys_map.get(str(client.mxid)) or {}\n         our_keys = our_user_devices.get(str(client.device_id))\n+        local_ed25519 = olm.account.identity_keys.get(\"ed25519\")\n \n         if not our_keys:\n             logger.warning(\"Matrix: device keys missing from server — re-uploading\")","@@ -312,21 +358,12 @@ async def _verify_device_keys_on_server(self, client: Any, olm: Any) -> bool:\n             except Exception as exc:\n                 logger.error(\"Matrix: failed to re-upload device keys: %s\", exc)\n                 return False\n-            return True\n+            return await self._reverify_keys_after_upload(client, local_ed25519)\n \n-        # DeviceKeys.keys is a dict[KeyID, str].  Iterate to find the\n-        # ed25519 key rather than constructing a KeyID for lookup.\n-        server_ed25519 = None\n-        keys_dict = getattr(our_keys, \"keys\", {}) or {}\n-        for key_id, key_value in keys_dict.items():\n-            if str(key_id).startswith(\"ed25519:\"):\n-                server_ed25519 = str(key_value)\n-                break\n-        local_ed25519 = olm.account.identity_keys.get(\"ed25519\")\n+        server_ed25519 = self._extract_server_ed25519(our_keys)\n \n         if server_ed25519 != local_ed25519:\n             if olm.account.shared:\n-                # Restored account from DB but server has different keys — corrupted state.\n                 logger.error(\n                     \"Matrix: server has different identity keys for device %s — \"\n                     \"local crypto state is stale. Delete %s and restart.\",","@@ -335,8 +372,6 @@ async def _verify_device_keys_on_server(self, client: Any, olm: Any) -> bool:\n                 )\n                 return False\n \n-            # Fresh account (never uploaded). Server has stale keys from a\n-            # previous installation. Try to delete the old device and re-upload.\n             logger.warning(\n                 \"Matrix: server has stale keys for device %s — attempting re-upload\",\n                 client.device_id,","@@ -348,10 +383,10 @@ async def _verify_device_keys_on_server(self, client: Any, olm: Any) -> bool:\n                     else \"DELETE\",\n                     f\"/_matrix/client/v3/devices/{client.device_id}\",\n                 )\n-                logger.info(\"Matrix: deleted stale device %s from server\", client.device_id)\n+                logger.info(\n+                    \"Matrix: deleted stale device %s from server\", client.device_id\n+                )\n             except Exception:\n-                # Device deletion often requires UIA or may simply not be\n-                # permitted — that's fine, share_keys will try to overwrite.\n                 pass\n             try:\n                 await olm.share_keys()","@@ -363,6 +398,7 @@ async def _verify_device_keys_on_server(self, client: Any, olm: Any) -> bool:\n                     exc,\n                 )\n                 return False\n+            return await self._reverify_keys_after_upload(client, local_ed25519)\n \n         return True\n ","@@ -448,7 +484,9 @@ async def connect(self) -> bool:\n                 await api.session.close()\n                 return False\n         else:\n-            logger.error(\"Matrix: need MATRIX_ACCESS_TOKEN or MATRIX_USER_ID + MATRIX_PASSWORD\")\n+            logger.error(\n+                \"Matrix: need MATRIX_ACCESS_TOKEN or MATRIX_USER_ID + MATRIX_PASSWORD\"\n+            )\n             await api.session.close()\n             return False\n ","@@ -472,7 +510,9 @@ async def connect(self) -> bool:\n                 # Remove legacy pickle file from pre-SQLite era.\n                 legacy_pickle = _STORE_DIR / \"crypto_store.pickle\"\n                 if legacy_pickle.exists():\n-                    logger.info(\"Matrix: removing legacy crypto_store.pickle (migrated to SQLite)\")\n+                    logger.info(\n+                        \"Matrix: removing legacy crypto_store.pickle (migrated to SQLite)\"\n+                    )\n                     legacy_pickle.unlink()\n \n                 # Open SQLite-backed crypto store.","@@ -508,6 +548,37 @@ async def connect(self) -> bool:\n                     await api.session.close()\n                     return False\n \n+                # Proactively flush one-time keys to detect stale OTK\n+                # conflicts early.  When crypto state is wiped but the\n+                # same device ID is reused, the server may still hold OTKs\n+                # signed with the old ed25519 key.  Identity key re-upload\n+                # succeeds but OTK uploads fail (\"already exists\" with\n+                # mismatched signature).  Peers then cannot establish Olm\n+                # sessions and all new messages are undecryptable.\n+                try:\n+                    await olm.share_keys()\n+                except Exception as exc:\n+                    exc_str = str(exc)\n+                    if \"already exists\" in exc_str:\n+                        logger.error(\n+                            \"Matrix: device %s has stale one-time keys on the \"\n+                            \"server signed with a previous identity key. \"\n+                            \"Peers cannot establish new Olm sessions with \"\n+                            \"this device. Delete the device from the \"\n+                            \"homeserver and restart, or generate a new \"\n+                            \"access token to get a fresh device ID.\",\n+                            client.device_id,\n+                        )\n+                        await crypto_db.stop()\n+                        await api.session.close()\n+                        return False\n+                    # Non-OTK errors are transient (network, etc.) — log\n+                    # but allow startup to continue.\n+                    logger.warning(\n+                        \"Matrix: share_keys() warning during startup: %s\",\n+                        exc,\n+                    )\n+\n                 # Import cross-signing private keys from SSSS and self-sign\n                 # the current device. Required after any device-key rotation\n                 # (fresh crypto.db, share_keys re-upload) — otherwise the","@@ -519,7 +590,9 @@ async def connect(self) -> bool:\n                         await olm.verify_with_recovery_key(recovery_key)\n                         logger.info(\"Matrix: cross-signing verified via recovery key\")\n                     except Exception as exc:\n-                        logger.warning(\"Matrix: recovery key verification failed: %s\", exc)\n+                        logger.warning(\n+                            \"Matrix: recovery key verification failed: %s\", exc\n+                        )\n \n                 client.crypto = olm\n                 logger.info(","@@ -530,21 +603,23 @@ async def connect(self) -> bool:\n             except Exception as exc:\n                 logger.error(\n                     \"Matrix: failed to create E2EE client: %s. %s\",\n-                    exc, _E2EE_INSTALL_HINT,\n+                    exc,\n+                    _E2EE_INSTALL_HINT,\n                 )\n                 await api.session.close()\n                 return False\n \n         # Register event handlers.\n         from mautrix.client import InternalEventType as IntEvt\n+        from mautrix.client.dispatcher import MembershipEventDispatcher\n+\n+        # Without this the INVITE handler below never fires.\n+        client.add_dispatcher(MembershipEventDispatcher)\n \n         client.add_event_handler(EventType.ROOM_MESSAGE, self._on_room_message)\n         client.add_event_handler(EventType.REACTION, self._on_reaction)\n         client.add_event_handler(IntEvt.INVITE, self._on_invite)\n \n-        if self._encryption and getattr(client, \"crypto\", None):\n-            client.add_event_handler(EventType.ROOM_ENCRYPTED, self._on_encrypted_event)\n-\n         # Initial sync to catch up, then start background sync.\n         self._startup_ts = time.time()\n         self._closing = False","@@ -553,7 +628,8 @@ async def connect(self) -> bool:\n             sync_data = await client.sync(timeout=10000, full_state=True)\n             if isinstance(sync_data, dict):\n                 rooms_join = sync_data.get(\"rooms\", {}).get(\"join\", {})\n-                self._joined_rooms = set(rooms_join.keys())\n+                self._joined_rooms.clear()\n+                self._joined_rooms.update(rooms_join.keys())\n                 # Store the next_batch token so incremental syncs start\n                 # from where the initial sync left off.\n                 nb = sync_data.get(\"next_batch\")","@@ -575,7 +651,10 @@ async def connect(self) -> bool:\n                 except Exception as exc:\n                     logger.warning(\"Matrix: initial sync event dispatch error: %s\", exc)\n             else:\n-                logger.warning(\"Matrix: initial sync returned unexpected type %s\", type(sync_data).__name__)\n+                logger.warning(\n+                    \"Matrix: initial sync returned unexpected type %s\",\n+                    type(sync_data).__name__,\n+                )\n         except Exception as exc:\n             logger.warning(\"Matrix: initial sync error: %s\", exc)\n ","@@ -648,9 +727,7 @@ async def send(\n \n             # Reply-to support.\n             if reply_to:\n-                msg_content[\"m.relates_to\"] = {\n-                    \"m.in_reply_to\": {\"event_id\": reply_to}\n-                }\n+                msg_content[\"m.relates_to\"] = {\"m.in_reply_to\": {\"event_id\": reply_to}}\n \n             # Thread support: if metadata has thread_id, send as threaded reply.\n             thread_id = (metadata or {}).get(\"thread_id\")","@@ -688,10 +765,18 @@ async def send(\n                             timeout=45,\n                         )\n                         last_event_id = str(event_id)\n-                        logger.info(\"Matrix: sent event %s to %s (after key share)\", last_event_id, chat_id)\n+                        logger.info(\n+                            \"Matrix: sent event %s to %s (after key share)\",\n+                            last_event_id,\n+                            chat_id,\n+                        )\n                         continue\n                     except Exception as retry_exc:\n-                        logger.error(\"Matrix: failed to send to %s after retry: %s\", chat_id, retry_exc)\n+                        logger.error(\n+                            \"Matrix: failed to send to %s after retry: %s\",\n+                            chat_id,\n+                            retry_exc,\n+                        )\n                         return SendResult(success=False, error=str(retry_exc))\n                 logger.error(\"Matrix: failed to send to %s: %s\", chat_id, exc)\n                 return SendResult(success=False, error=str(exc))","@@ -706,7 +791,8 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:\n         if self._client:\n             try:\n                 name_evt = await self._client.get_state_event(\n-                    RoomID(chat_id), EventType.ROOM_NAME,\n+                    RoomID(chat_id),\n+                    EventType.ROOM_NAME,\n                 )\n                 if name_evt and hasattr(name_evt, \"name\") and name_evt.name:\n                     name = name_evt.name","@@ -730,13 +816,14 @@ async def send_typing(\n                 pass\n \n     async def stop_typing(self, chat_id: str) -> None:\n-        \"\"\"Stop the Matrix typing indicator.\"\"\"\n+        \"\"\"Clear the typing indicator.\"\"\"\n         if self._client:\n             try:\n                 await self._client.set_typing(RoomID(chat_id), timeout=0)\n             except Exception:\n                 pass\n \n+\n     async def edit_message(\n         self, chat_id: str, message_id: str, content: str\n     ) -> SendResult:","@@ -765,7 +852,9 @@ async def edit_message(\n \n         try:\n             event_id = await self._client.send_message_event(\n-                RoomID(chat_id), EventType.ROOM_MESSAGE, msg_content,\n+                RoomID(chat_id),\n+                EventType.ROOM_MESSAGE,\n+                msg_content,\n             )\n             return SendResult(success=True, message_id=str(event_id))\n         except Exception as exc:","@@ -781,22 +870,31 @@ async def send_image(\n     ) -> SendResult:\n         \"\"\"Download an image URL and upload it to Matrix.\"\"\"\n         from tools.url_safety import is_safe_url\n+\n         if not is_safe_url(image_url):\n             logger.warning(\"Matrix: blocked unsafe image URL (SSRF protection)\")\n-            return await super().send_image(chat_id, image_url, caption, reply_to, metadata=metadata)\n+            return await super().send_image(\n+                chat_id, image_url, caption, reply_to, metadata=metadata\n+            )\n \n         try:\n             # Try aiohttp first (always available), fall back to httpx\n             try:\n                 import aiohttp as _aiohttp\n+\n                 async with _aiohttp.ClientSession(trust_env=True) as http:\n-                    async with http.get(image_url, timeout=_aiohttp.ClientTimeout(total=30)) as resp:\n+                    async with http.get(\n+                        image_url, timeout=_aiohttp.ClientTimeout(total=30)\n+                    ) as resp:\n                         resp.raise_for_status()\n                         data = await resp.read()\n                         ct = resp.content_type or \"image/png\"\n-                        fname = image_url.rsplit(\"/\", 1)[-1].split(\"?\")[0] or \"image.png\"\n+                        fname = (\n+                            image_url.rsplit(\"/\", 1)[-1].split(\"?\")[0] or \"image.png\"\n+                        )\n             except ImportError:\n                 import httpx\n+\n                 async with httpx.AsyncClient() as http:\n                     resp = await http.get(image_url, follow_redirects=True, timeout=30)\n                     resp.raise_for_status()","@@ -805,9 +903,13 @@ async def send_image(\n                     fname = image_url.rsplit(\"/\", 1)[-1].split(\"?\")[0] or \"image.png\"\n         except Exception as exc:\n             logger.warning(\"Matrix: failed to download image %s: %s\", image_url, exc)\n-            return await self.send(chat_id, f\"{caption or ''}\\n{image_url}\".strip(), reply_to)\n+            return await self.send(\n+                chat_id, f\"{caption or ''}\\n{image_url}\".strip(), reply_to\n+            )\n \n-        return await self._upload_and_send(chat_id, data, fname, ct, \"m.image\", caption, reply_to, metadata)\n+        return await self._upload_and_send(\n+            chat_id, data, fname, ct, \"m.image\", caption, reply_to, metadata\n+        )\n \n     async def send_image_file(\n         self,","@@ -818,7 +920,9 @@ async def send_image_file(\n         metadata: Optional[Dict[str, Any]] = None,\n     ) -> SendResult:\n         \"\"\"Upload a local image file to Matrix.\"\"\"\n-        return await self._send_local_file(chat_id, image_path, \"m.image\", caption, reply_to, metadata=metadata)\n+        return await self._send_local_file(\n+            chat_id, image_path, \"m.image\", caption, reply_to, metadata=metadata\n+        )\n \n     async def send_document(\n         self,","@@ -830,7 +934,9 @@ async def send_document(\n         metadata: Optional[Dict[str, Any]] = None,\n     ) -> SendResult:\n         \"\"\"Upload a local file as a document.\"\"\"\n-        return await self._send_local_file(chat_id, file_path, \"m.file\", caption, reply_to, file_name, metadata)\n+        return await self._send_local_file(\n+            chat_id, file_path, \"m.file\", caption, reply_to, file_name, metadata\n+        )\n \n     async def send_voice(\n         self,","@@ -842,8 +948,13 @@ async def send_voice(\n     ) -> SendResult:\n         \"\"\"Upload an audio file as a voice message (MSC3245 native voice).\"\"\"\n         return await self._send_local_file(\n-            chat_id, audio_path, \"m.audio\", caption, reply_to,\n-            metadata=metadata, is_voice=True\n+            chat_id,\n+            audio_path,\n+            \"m.audio\",\n+            caption,\n+            reply_to,\n+            metadata=metadata,\n+            is_voice=True,\n         )\n \n     async def send_video(","@@ -855,7 +966,9 @@ async def send_video(\n         metadata: Optional[Dict[str, Any]] = None,\n     ) -> SendResult:\n         \"\"\"Upload a video file.\"\"\"\n-        return await self._send_local_file(chat_id, video_path, \"m.video\", caption, reply_to, metadata=metadata)\n+        return await self._send_local_file(\n+            chat_id, video_path, \"m.video\", caption, reply_to, metadata=metadata\n+        )\n \n     def format_message(self, content: str) -> str:\n         \"\"\"Pass-through — Matrix supports standard Markdown natively.\"\"\"","@@ -881,12 +994,30 @@ async def _upload_and_send(\n     ) -> SendResult:\n         \"\"\"Upload bytes to Matrix and send as a media message.\"\"\"\n \n+        upload_data = data\n+        encrypted_file = None\n+        if self._encryption and getattr(self._client, \"crypto\", None):\n+            state_store = getattr(self._client, \"state_store\", None)\n+            if state_store:\n+                try:\n+                    room_encrypted = bool(await state_store.is_encrypted(RoomID(room_id)))\n+                except Exception:\n+                    room_encrypted = False\n+                if room_encrypted:\n+                    try:\n+                        from mautrix.crypto.attachments import encrypt_attachment\n+                        upload_data, encrypted_file = encrypt_attachment(data)\n+                    except Exception as exc:\n+                        logger.error(\"Matrix: attachment encryption failed: %s\", exc)\n+                        return SendResult(success=False, error=str(exc))\n+\n         # Upload to homeserver.\n         try:\n             mxc_url = await self._client.upload_media(\n-                data,\n+                upload_data,\n                 mime_type=content_type,\n                 filename=filename,\n+                size=len(upload_data),\n             )\n         except Exception as exc:\n             logger.error(\"Matrix: upload failed: %s\", exc)","@@ -896,21 +1027,24 @@ async def _upload_and_send(\n         msg_content: Dict[str, Any] = {\n             \"msgtype\": msgtype,\n             \"body\": caption or filename,\n-            \"url\": str(mxc_url),\n             \"info\": {\n                 \"mimetype\": content_type,\n                 \"size\": len(data),\n             },\n         }\n+        if encrypted_file is not None:\n+            file_payload = encrypted_file.serialize()\n+            file_payload[\"url\"] = str(mxc_url)\n+            msg_content[\"file\"] = file_payload\n+        else:\n+            msg_content[\"url\"] = str(mxc_url)\n \n         # Add MSC3245 voice flag for native voice messages.\n         if is_voice:\n             msg_content[\"org.matrix.msc3245.voice\"] = {}\n \n         if reply_to:\n-            msg_content[\"m.relates_to\"] = {\n-                \"m.in_reply_to\": {\"event_id\": reply_to}\n-            }\n+            msg_content[\"m.relates_to\"] = {\"m.in_reply_to\": {\"event_id\": reply_to}}\n \n         thread_id = (metadata or {}).get(\"thread_id\")\n         if thread_id:","@@ -922,7 +1056,9 @@ async def _upload_and_send(\n \n         try:\n             event_id = await self._client.send_message_event(\n-                RoomID(room_id), EventType.ROOM_MESSAGE, msg_content,\n+                RoomID(room_id),\n+                EventType.ROOM_MESSAGE,\n+                msg_content,\n             )\n             return SendResult(success=True, message_id=str(event_id))\n         except Exception as exc:","@@ -940,7 +1076,7 @@ async def _send_local_file(\n         is_voice: bool = False,\n     ) -> SendResult:\n         \"\"\"Read a local file and upload it.\"\"\"\n-        p = Path(file_path)\n+        p = Path(file_path).expanduser()\n         if not p.exists():\n             return await self.send(\n                 room_id, f\"{caption or ''}\\n(file not found: {file_path})\", reply_to","@@ -950,7 +1086,9 @@ async def _send_local_file(\n         ct = mimetypes.guess_type(fname)[0] or \"application/octet-stream\"\n         data = p.read_bytes()\n \n-        return await self._upload_and_send(room_id, data, fname, ct, msgtype, caption, reply_to, metadata, is_voice)\n+        return await self._upload_and_send(\n+            room_id, data, fname, ct, msgtype, caption, reply_to, metadata, is_voice\n+        )\n \n     # ------------------------------------------------------------------\n     # Sync loop","@@ -964,7 +1102,8 @@ async def _sync_loop(self) -> None:\n         while not self._closing:\n             try:\n                 sync_data = await client.sync(\n-                    since=next_batch, timeout=30000,\n+                    since=next_batch,\n+                    timeout=30000,\n                 )\n \n                 # nio returns SyncError objects (not exceptions) for auth","@@ -973,7 +1112,10 @@ async def _sync_loop(self) -> None:\n                 if _sync_msg and isinstance(_sync_msg, str):\n                     _lower = _sync_msg.lower()\n                     if \"m_unknown_token\" in _lower or \"unknown_token\" in _lower:\n-                        logger.error(\"Matrix: permanent auth error from sync: %s — stopping\", _sync_msg)\n+                        logger.error(\n+                            \"Matrix: permanent auth error from sync: %s — stopping\",\n+                            _sync_msg,\n+                        )\n                         return\n \n                 if isinstance(sync_data, dict):","@@ -998,10 +1140,6 @@ async def _sync_loop(self) -> None:\n                     except Exception as exc:\n                         logger.warning(\"Matrix: sync event dispatch error: %s\", exc)\n \n-                # Retry any buffered undecrypted events.\n-                if self._pending_megolm:\n-                    await self._retry_pending_decryptions()\n-\n             except asyncio.CancelledError:\n                 return\n             except Exception as exc:","@@ -1009,64 +1147,19 @@ async def _sync_loop(self) -> None:\n                     return\n                 # Detect permanent auth/permission failures.\n                 err_str = str(exc).lower()\n-                if \"401\" in err_str or \"403\" in err_str or \"unauthorized\" in err_str or \"forbidden\" in err_str:\n-                    logger.error(\"Matrix: permanent auth error: %s — stopping sync\", exc)\n+                if (\n+                    \"401\" in err_str\n+                    or \"403\" in err_str\n+                    or \"unauthorized\" in err_str\n+                    or \"forbidden\" in err_str\n+                ):\n+                    logger.error(\n+                        \"Matrix: permanent auth error: %s — stopping sync\", exc\n+                    )\n                     return\n                 logger.warning(\"Matrix: sync error: %s — retrying in 5s\", exc)\n                 await asyncio.sleep(5)\n \n-    async def _retry_pending_decryptions(self) -> None:\n-        \"\"\"Retry decrypting buffered encrypted events after new keys arrive.\"\"\"\n-        client = self._client\n-        if not client or not self._pending_megolm:\n-            return\n-        crypto = getattr(client, \"crypto\", None)\n-        if not crypto:\n-            return\n-\n-        now = time.time()\n-        still_pending: list = []\n-\n-        for room_id, event, ts in self._pending_megolm:\n-            # Drop events that have aged past the TTL.\n-            if now - ts > _PENDING_EVENT_TTL:\n-                logger.debug(\n-                    \"Matrix: dropping expired pending event %s (age %.0fs)\",\n-                    getattr(event, \"event_id\", \"?\"), now - ts,\n-                )\n-                continue\n-\n-            try:\n-                decrypted = await crypto.decrypt_megolm_event(event)\n-            except Exception:\n-                still_pending.append((room_id, event, ts))\n-                continue\n-\n-            if decrypted is None or decrypted is event:\n-                still_pending.append((room_id, event, ts))\n-                continue\n-\n-            logger.info(\n-                \"Matrix: decrypted buffered event %s\",\n-                getattr(event, \"event_id\", \"?\"),\n-            )\n-\n-            # Route to the appropriate handler.\n-            # Remove from dedup set so _on_room_message doesn't drop it\n-            # (the encrypted event ID was already registered by _on_encrypted_event).\n-            decrypted_id = str(getattr(decrypted, \"event_id\", getattr(event, \"event_id\", \"\")))\n-            if decrypted_id:\n-                self._processed_events_set.discard(decrypted_id)\n-            try:\n-                await self._on_room_message(decrypted)\n-            except Exception as exc:\n-                logger.warning(\n-                    \"Matrix: error processing decrypted event %s: %s\",\n-                    getattr(event, \"event_id\", \"?\"), exc,\n-                )\n-\n-        self._pending_megolm = still_pending\n-\n     # ------------------------------------------------------------------\n     # Event callbacks\n     # ------------------------------------------------------------------","@@ -1086,7 +1179,11 @@ async def _on_room_message(self, event: Any) -> None:\n             return\n \n         # Startup grace: ignore old messages from initial sync.\n-        raw_ts = getattr(event, \"timestamp\", None) or getattr(event, \"server_timestamp\", None) or 0\n+        raw_ts = (\n+            getattr(event, \"timestamp\", None)\n+            or getattr(event, \"server_timestamp\", None)\n+            or 0\n+        )\n         event_ts = raw_ts / 1000.0 if raw_ts else 0.0\n         if event_ts and event_ts < self._startup_ts - _STARTUP_GRACE_SECONDS:\n             return","@@ -1126,9 +1223,13 @@ async def _on_room_message(self, event: Any) -> None:\n         # Dispatch by msgtype.\n         media_msgtypes = (\"m.image\", \"m.audio\", \"m.video\", \"m.file\")\n         if msgtype in media_msgtypes:\n-            await self._handle_media_message(room_id, sender, event_id, event_ts, source_content, relates_to, msgtype)\n+            await self._handle_media_message(\n+                room_id, sender, event_id, event_ts, source_content, relates_to, msgtype\n+            )\n         elif msgtype == \"m.text\":\n-            await self._handle_text_message(room_id, sender, event_id, event_ts, source_content, relates_to)\n+            await self._handle_text_message(\n+                room_id, sender, event_id, event_ts, source_content, relates_to\n+            )\n \n     async def _resolve_message_context(\n         self,","@@ -1154,7 +1255,9 @@ async def _resolve_message_context(\n         formatted_body = source_content.get(\"formatted_body\")\n         # m.mentions.user_ids (MSC3952 / Matrix v1.7) — authoritative mention signal.\n         mentions_block = source_content.get(\"m.mentions\") or {}\n-        mention_user_ids = mentions_block.get(\"user_ids\") if isinstance(mentions_block, dict) else None\n+        mention_user_ids = (\n+            mentions_block.get(\"user_ids\") if isinstance(mentions_block, dict) else None\n+        )\n         is_mentioned = self._is_bot_mentioned(body, formatted_body, mention_user_ids)\n \n         # Require-mention gating.","@@ -1170,8 +1273,8 @@ async def _resolve_message_context(\n             thread_id = event_id\n             self._threads.mark(thread_id)\n \n-        # Strip mention from body.\n-        if is_mentioned:\n+        # Strip mention from body (only when mention-gating is active).\n+        if is_mentioned and self._require_mention:\n             body = self._strip_mention(body)\n \n         # Auto-thread.","@@ -1210,7 +1313,12 @@ async def _handle_text_message(\n             return\n \n         ctx = await self._resolve_message_context(\n-            room_id, sender, event_id, body, source_content, relates_to,\n+            room_id,\n+            sender,\n+            event_id,\n+            body,\n+            source_content,\n+            relates_to,\n         )\n         if ctx is None:\n             return","@@ -1288,7 +1396,9 @@ async def _handle_media_message(\n             if url and url.startswith(\"mxc://\"):\n                 http_url = self._mxc_to_http(url)\n \n-        is_encrypted_media = bool(file_content and isinstance(file_content, dict) and file_content.get(\"url\"))\n+        is_encrypted_media = bool(\n+            file_content and isinstance(file_content, dict) and file_content.get(\"url\")\n+        )\n \n         media_type = \"application/octet-stream\"\n         msg_type = MessageType.DOCUMENT","@@ -1312,9 +1422,9 @@ async def _handle_media_message(\n \n         # Cache media locally when downstream tools need a real file path.\n         cached_path = None\n-        should_cache_locally = (\n-            msg_type == MessageType.PHOTO or is_voice_message or is_encrypted_media\n-        )\n+        should_cache_locally = msg_type in (\n+            MessageType.PHOTO, MessageType.AUDIO, MessageType.VIDEO, MessageType.DOCUMENT,\n+        ) or is_voice_message or is_encrypted_media\n         if should_cache_locally and url:\n             try:\n                 file_bytes = await self._client.download_media(ContentURI(url))","@@ -1322,17 +1432,35 @@ async def _handle_media_message(\n                     if is_encrypted_media:\n                         from mautrix.crypto.attachments import decrypt_attachment\n \n-                        hashes_value = file_content.get(\"hashes\") if isinstance(file_content, dict) else None\n-                        hash_value = hashes_value.get(\"sha256\") if isinstance(hashes_value, dict) else None\n+                        hashes_value = (\n+                            file_content.get(\"hashes\")\n+                            if isinstance(file_content, dict)\n+                            else None\n+                        )\n+                        hash_value = (\n+                            hashes_value.get(\"sha256\")\n+                            if isinstance(hashes_value, dict)\n+                            else None\n+                        )\n \n-                        key_value = file_content.get(\"key\") if isinstance(file_content, dict) else None\n+                        key_value = (\n+                            file_content.get(\"key\")\n+                            if isinstance(file_content, dict)\n+                            else None\n+                        )\n                         if isinstance(key_value, dict):\n                             key_value = key_value.get(\"k\")\n \n-                        iv_value = file_content.get(\"iv\") if isinstance(file_content, dict) else None\n+                        iv_value = (\n+                            file_content.get(\"iv\")\n+                            if isinstance(file_content, dict)\n+                            else None\n+                        )\n \n                         if key_value and hash_value and iv_value:\n-                            file_bytes = decrypt_attachment(file_bytes, key_value, hash_value, iv_value)\n+                            file_bytes = decrypt_attachment(\n+                                file_bytes, key_value, hash_value, iv_value\n+                            )\n                         else:\n                             logger.warning(\n                                 \"[Matrix] Encrypted media event missing decryption metadata for %s\",","@@ -1358,25 +1486,46 @@ async def _handle_media_message(\n                             cached_path = cache_image_from_bytes(file_bytes, ext=ext)\n                             logger.info(\"[Matrix] Cached user image at %s\", cached_path)\n                         elif msg_type in (MessageType.AUDIO, MessageType.VOICE):\n-                            ext = Path(body or (\"voice.ogg\" if is_voice_message else \"audio.ogg\")).suffix or \".ogg\"\n+                            ext = (\n+                                Path(\n+                                    body\n+                                    or (\n+                                        \"voice.ogg\" if is_voice_message else \"audio.ogg\"\n+                                    )\n+                                ).suffix\n+                                or \".ogg\"\n+                            )\n                             cached_path = cache_audio_from_bytes(file_bytes, ext=ext)\n                         else:\n                             filename = body or (\n-                                \"video.mp4\" if msg_type == MessageType.VIDEO else \"document\"\n+                                \"video.mp4\"\n+                                if msg_type == MessageType.VIDEO\n+                                else \"document\"\n+                            )\n+                            cached_path = cache_document_from_bytes(\n+                                file_bytes, filename\n                             )\n-                            cached_path = cache_document_from_bytes(file_bytes, filename)\n             except Exception as e:\n                 logger.warning(\"[Matrix] Failed to cache media: %s\", e)\n \n         ctx = await self._resolve_message_context(\n-            room_id, sender, event_id, body, source_content, relates_to,\n+            room_id,\n+            sender,\n+            event_id,\n+            body,\n+            source_content,\n+            relates_to,\n         )\n         if ctx is None:\n             return\n         body, is_dm, chat_type, thread_id, display_name, source = ctx\n \n         allow_http_fallback = bool(http_url) and not is_encrypted_media\n-        media_urls = [cached_path] if cached_path else ([http_url] if allow_http_fallback else None)\n+        media_urls = (\n+            [cached_path]\n+            if cached_path\n+            else ([http_url] if allow_http_fallback else None)\n+        )\n         media_types = [media_type] if media_urls else None\n \n         msg_event = MessageEvent(","@@ -1391,23 +1540,6 @@ async def _handle_media_message(\n \n         await self.handle_message(msg_event)\n \n-    async def _on_encrypted_event(self, event: Any) -> None:\n-        \"\"\"Handle encrypted events that could not be auto-decrypted.\"\"\"\n-        room_id = str(getattr(event, \"room_id\", \"\"))\n-        event_id = str(getattr(event, \"event_id\", \"\"))\n-\n-        if self._is_duplicate_event(event_id):\n-            return\n-\n-        logger.warning(\n-            \"Matrix: could not decrypt event %s in %s — buffering for retry\",\n-            event_id, room_id,\n-        )\n-\n-        self._pending_megolm.append((room_id, event, time.time()))\n-        if len(self._pending_megolm) > _MAX_PENDING_EVENTS:\n-            self._pending_megolm = self._pending_megolm[-_MAX_PENDING_EVENTS:]\n-\n     async def _on_invite(self, event: Any) -> None:\n         \"\"\"Auto-join rooms when invited.\"\"\"\n ","@@ -1430,7 +1562,10 @@ async def _on_invite(self, event: Any) -> None:\n     # ------------------------------------------------------------------\n \n     async def _send_reaction(\n-        self, room_id: str, event_id: str, emoji: str,\n+        self,\n+        room_id: str,\n+        event_id: str,\n+        emoji: str,\n     ) -> Optional[str]:\n         \"\"\"Send an emoji reaction to a message in a room.\n         Returns the reaction event_id on success, None on failure.","@@ -1447,7 +1582,9 @@ async def _send_reaction(\n         }\n         try:\n             resp_event_id = await self._client.send_message_event(\n-                RoomID(room_id), EventType.REACTION, content,\n+                RoomID(room_id),\n+                EventType.REACTION,\n+                content,\n             )\n             logger.debug(\"Matrix: sent reaction %s to %s\", emoji, event_id)\n             return str(resp_event_id)","@@ -1456,7 +1593,10 @@ async def _send_reaction(\n             return None\n \n     async def _redact_reaction(\n-        self, room_id: str, reaction_event_id: str, reason: str = \"\",\n+        self,\n+        room_id: str,\n+        reaction_event_id: str,\n+        reason: str = \"\",\n     ) -> bool:\n         \"\"\"Remove a reaction by redacting its event.\"\"\"\n         return await self.redact_message(room_id, reaction_event_id, reason)","@@ -1473,7 +1613,9 @@ async def on_processing_start(self, event: MessageEvent) -> None:\n                 self._pending_reactions[(room_id, msg_id)] = reaction_event_id\n \n     async def on_processing_complete(\n-        self, event: MessageEvent, outcome: ProcessingOutcome,\n+        self,\n+        event: MessageEvent,\n+        outcome: ProcessingOutcome,\n     ) -> None:\n         \"\"\"Replace eyes with checkmark (success) or cross (failure).\"\"\"\n         if not self._reactions_enabled:","@@ -1507,7 +1649,11 @@ async def _on_reaction(self, event: Any) -> None:\n         room_id = str(getattr(event, \"room_id\", \"\"))\n         content = getattr(event, \"content\", None)\n         if content:\n-            relates_to = content.get(\"m.relates_to\", {}) if isinstance(content, dict) else getattr(content, \"relates_to\", {})\n+            relates_to = (\n+                content.get(\"m.relates_to\", {})\n+                if isinstance(content, dict)\n+                else getattr(content, \"relates_to\", {})\n+            )\n             reacts_to = \"\"\n             key = \"\"\n             if isinstance(relates_to, dict):","@@ -1518,7 +1664,10 @@ async def _on_reaction(self, event: Any) -> None:\n                 key = str(getattr(relates_to, \"key\", \"\"))\n             logger.info(\n                 \"Matrix: reaction %s from %s on %s in %s\",\n-                key, sender, reacts_to, room_id,\n+                key,\n+                sender,\n+                reacts_to,\n+                room_id,\n             )\n \n     # ------------------------------------------------------------------","@@ -1528,10 +1677,15 @@ async def _on_reaction(self, event: Any) -> None:\n     def _text_batch_key(self, event: MessageEvent) -> str:\n         \"\"\"Session-scoped key for text message batching.\"\"\"\n         from gateway.session import build_session_key\n+\n         return build_session_key(\n             event.source,\n-            group_sessions_per_user=self.config.extra.get(\"group_sessions_per_user\", True),\n-            thread_sessions_per_user=self.config.extra.get(\"thread_sessions_per_user\", False),\n+            group_sessions_per_user=self.config.extra.get(\n+                \"group_sessions_per_user\", True\n+            ),\n+            thread_sessions_per_user=self.config.extra.get(\n+                \"thread_sessions_per_user\", False\n+            ),\n         )\n \n     def _enqueue_text_event(self, event: MessageEvent) -> None:","@@ -1544,7 +1698,9 @@ def _enqueue_text_event(self, event: MessageEvent) -> None:\n             self._pending_text_batches[key] = event\n         else:\n             if event.text:\n-                existing.text = f\"{existing.text}\\n{event.text}\" if existing.text else event.text\n+                existing.text = (\n+                    f\"{existing.text}\\n{event.text}\" if existing.text else event.text\n+                )\n             existing._last_chunk_len = chunk_len  # type: ignore[attr-defined]\n             if event.media_urls:\n                 existing.media_urls.extend(event.media_urls)","@@ -1573,7 +1729,8 @@ async def _flush_text_batch(self, key: str) -> None:\n                 return\n             logger.info(\n                 \"[Matrix] Flushing text batch %s (%d chars)\",\n-                key, len(event.text or \"\"),\n+                key,\n+                len(event.text or \"\"),\n             )\n             await self.handle_message(event)\n         finally:","@@ -1586,11 +1743,13 @@ async def _flush_text_batch(self, key: str) -> None:\n \n     def _background_read_receipt(self, room_id: str, event_id: str) -> None:\n         \"\"\"Fire-and-forget read receipt with error logging.\"\"\"\n+\n         async def _send() -> None:\n             try:\n                 await self.send_read_receipt(room_id, event_id)\n             except Exception as exc:  # pragma: no cover — defensive\n                 logger.debug(\"Matrix: background read receipt failed: %s\", exc)\n+\n         asyncio.ensure_future(_send())\n \n     async def send_read_receipt(self, room_id: str, event_id: str) -> bool:","@@ -1614,14 +1773,19 @@ async def send_read_receipt(self, room_id: str, event_id: str) -> bool:\n     # ------------------------------------------------------------------\n \n     async def redact_message(\n-        self, room_id: str, event_id: str, reason: str = \"\",\n+        self,\n+        room_id: str,\n+        event_id: str,\n+        reason: str = \"\",\n     ) -> bool:\n         \"\"\"Redact (delete) a message or event from a room.\"\"\"\n         if not self._client:\n             return False\n         try:\n             await self._client.redact(\n-                RoomID(room_id), EventID(event_id), reason=reason or None,\n+                RoomID(room_id),\n+                EventID(event_id),\n+                reason=reason or None,\n             )\n             logger.info(\"Matrix: redacted %s in %s\", event_id, room_id)\n             return True","@@ -1712,7 +1876,10 @@ async def set_presence(self, state: str = \"online\", status_msg: str = \"\") -> boo\n     # ------------------------------------------------------------------\n \n     async def _send_simple_message(\n-        self, chat_id: str, text: str, msgtype: str,\n+        self,\n+        chat_id: str,\n+        text: str,\n+        msgtype: str,\n     ) -> SendResult:\n         \"\"\"Send a simple message (emote, notice) with optional HTML formatting.\"\"\"\n         if not self._client or not text:","@@ -1726,7 +1893,9 @@ async def _send_simple_message(\n \n         try:\n             event_id = await self._client.send_message_event(\n-                RoomID(chat_id), EventType.ROOM_MESSAGE, msg_content,\n+                RoomID(chat_id),\n+                EventType.ROOM_MESSAGE,\n+                msg_content,\n             )\n             return SendResult(success=True, message_id=str(event_id))\n         except Exception as exc:","@@ -1741,7 +1910,9 @@ async def _is_dm_room(self, room_id: str) -> bool:\n         if self._dm_rooms.get(room_id, False):\n             return True\n         # Fallback: check member count via state store.\n-        state_store = getattr(self._client, \"state_store\", None) if self._client else None\n+        state_store = (\n+            getattr(self._client, \"state_store\", None) if self._client else None\n+        )\n         if state_store:\n             try:\n                 members = await state_store.get_members(room_id)","@@ -1775,10 +1946,7 @@ async def _refresh_dm_cache(self) -> None:\n             if isinstance(rooms, list):\n                 dm_room_ids.update(str(r) for r in rooms)\n \n-        self._dm_rooms = {\n-            rid: (rid in dm_room_ids)\n-            for rid in self._joined_rooms\n-        }\n+        self._dm_rooms = {rid: (rid in dm_room_ids) for rid in self._joined_rooms}\n \n     # ------------------------------------------------------------------\n     # Mention detection helpers","@@ -1808,7 +1976,9 @@ def _is_bot_mentioned(\n             return True\n         if self._user_id and \":\" in self._user_id:\n             localpart = self._user_id.split(\":\")[0].lstrip(\"@\")\n-            if localpart and re.search(r'\\b' + re.escape(localpart) + r'\\b', body, re.IGNORECASE):\n+            if localpart and re.search(\n+                r\"\\b\" + re.escape(localpart) + r\"\\b\", body, re.IGNORECASE\n+            ):\n                 return True\n         if formatted_body and self._user_id:\n             if f\"matrix.to/#/{self._user_id}\" in formatted_body:","@@ -1816,18 +1986,20 @@ def _is_bot_mentioned(\n         return False\n \n     def _strip_mention(self, body: str) -> str:\n-        \"\"\"Remove bot mention from message body.\"\"\"\n+        \"\"\"Strip the bot's full MXID (``@user:server``) from *body*.\n+\n+        The bare localpart is intentionally *not* stripped — it would\n+        mangle file paths like ``/home/hermes/media/file.png``.\n+        \"\"\"\n         if self._user_id:\n             body = body.replace(self._user_id, \"\")\n-        if self._user_id and \":\" in self._user_id:\n-            localpart = self._user_id.split(\":\")[0].lstrip(\"@\")\n-            if localpart:\n-                body = re.sub(r'\\b' + re.escape(localpart) + r'\\b', '', body, flags=re.IGNORECASE)\n         return body.strip()\n \n     async def _get_display_name(self, room_id: str, user_id: str) -> str:\n         \"\"\"Get a user's display name in a room, falling back to user_id.\"\"\"\n-        state_store = getattr(self._client, \"state_store\", None) if self._client else None\n+        state_store = (\n+            getattr(self._client, \"state_store\", None) if self._client else None\n+        )\n         if state_store:\n             try:\n                 member = await state_store.get_member(room_id, user_id)","@@ -1915,9 +2087,7 @@ def _protect_html(html_fragment: str) -> str:\n         # Inline code: `code`\n         result = re.sub(\n             r\"`([^`\\n]+)`\",\n-            lambda m: _protect_html(\n-                f\"<code>{_html_escape(m.group(1))}</code>\"\n-            ),\n+            lambda m: _protect_html(f\"<code>{_html_escape(m.group(1))}</code>\"),\n             result,\n         )\n ","@@ -1962,11 +2132,18 @@ def _protect_html(html_fragment: str) -> str:\n                 continue\n \n             # Blockquote\n-            if line.startswith(\"&gt; \") or line == \"&gt;\" or line.startswith(\"> \") or line == \">\":\n+            if (\n+                line.startswith(\"&gt; \")\n+                or line == \"&gt;\"\n+                or line.startswith(\"> \")\n+                or line == \">\"\n+            ):\n                 bq_lines = []\n                 while i < len(lines) and (\n-                    lines[i].startswith(\"&gt; \") or lines[i] == \"&gt;\"\n-                    or lines[i].startswith(\"> \") or lines[i] == \">\"\n+                    lines[i].startswith(\"&gt; \")\n+                    or lines[i] == \"&gt;\"\n+                    or lines[i].startswith(\"> \")\n+                    or lines[i] == \">\"\n                 ):\n                     ln = lines[i]\n                     if ln.startswith(\"&gt; \"):","@@ -2007,13 +2184,19 @@ def _protect_html(html_fragment: str) -> str:\n         result = \"\\n\".join(out_lines)\n \n         # Inline transforms.\n-        result = re.sub(r\"\\*\\*(.+?)\\*\\*\", r\"<strong>\\1</strong>\", result, flags=re.DOTALL)\n+        result = re.sub(\n+            r\"\\*\\*(.+?)\\*\\*\", r\"<strong>\\1</strong>\", result, flags=re.DOTALL\n+        )\n         result = re.sub(r\"__(.+?)__\", r\"<strong>\\1</strong>\", result, flags=re.DOTALL)\n         result = re.sub(r\"\\*(.+?)\\*\", r\"<em>\\1</em>\", result, flags=re.DOTALL)\n-        result = re.sub(r\"(?<!\\w)_(.+?)_(?!\\w)\", r\"<em>\\1</em>\", result, flags=re.DOTALL)\n+        result = re.sub(\n+            r\"(?<!\\w)_(.+?)_(?!\\w)\", r\"<em>\\1</em>\", result, flags=re.DOTALL\n+        )\n         result = re.sub(r\"~~(.+?)~~\", r\"<del>\\1</del>\", result, flags=re.DOTALL)\n         result = re.sub(r\"\\n\", \"<br>\\n\", result)\n-        result = re.sub(r\"<br>\\n(</?(?:pre|blockquote|h[1-6]|ul|ol|li|hr))\", r\"\\n\\1\", result)\n+        result = re.sub(\n+            r\"<br>\\n(</?(?:pre|blockquote|h[1-6]|ul|ol|li|hr))\", r\"\\n\\1\", result\n+        )\n         result = re.sub(r\"(</(?:pre|blockquote|h[1-6]|ul|ol|li)>)<br>\", r\"\\1\", result)\n \n         # Restore protected regions."]},{"file":"gateway/run.py","hunks":["@@ -7929,12 +7929,15 @@ async def _run_agent_via_proxy(\n                 if _adapter:\n                     _adapter_supports_edit = getattr(_adapter, \"SUPPORTS_MESSAGE_EDITING\", True)\n                     _effective_cursor = _scfg.cursor if _adapter_supports_edit else \"\"\n+                    _buffer_only = False\n                     if source.platform == Platform.MATRIX:\n                         _effective_cursor = \"\"\n+                        _buffer_only = True\n                     _consumer_cfg = StreamConsumerConfig(\n                         edit_interval=_scfg.edit_interval,\n                         buffer_threshold=_scfg.buffer_threshold,\n                         cursor=_effective_cursor,\n+                        buffer_only=_buffer_only,\n                     )\n                     _stream_consumer = GatewayStreamConsumer(\n                         adapter=_adapter,","@@ -8500,12 +8503,15 @@ def run_sync():\n                         # Some Matrix clients render the streaming cursor\n                         # as a visible tofu/white-box artifact.  Keep\n                         # streaming text on Matrix, but suppress the cursor.\n+                        _buffer_only = False\n                         if source.platform == Platform.MATRIX:\n                             _effective_cursor = \"\"\n+                            _buffer_only = True\n                         _consumer_cfg = StreamConsumerConfig(\n                             edit_interval=_scfg.edit_interval,\n                             buffer_threshold=_scfg.buffer_threshold,\n                             cursor=_effective_cursor,\n+                            buffer_only=_buffer_only,\n                         )\n                         _stream_consumer = GatewayStreamConsumer(\n                             adapter=_adapter,"]},{"file":"gateway/stream_consumer.py","hunks":["@@ -43,6 +43,7 @@ class StreamConsumerConfig:\n     edit_interval: float = 1.0\n     buffer_threshold: int = 40\n     cursor: str = \" ▉\"\n+    buffer_only: bool = False\n \n \n class GatewayStreamConsumer:","@@ -295,10 +296,13 @@ async def run(self) -> None:\n                     got_done\n                     or got_segment_break\n                     or commentary_text is not None\n-                    or (elapsed >= self._current_edit_interval\n-                        and self._accumulated)\n-                    or len(self._accumulated) >= self.cfg.buffer_threshold\n                 )\n+                if not self.cfg.buffer_only:\n+                    should_edit = should_edit or (\n+                        (elapsed >= self._current_edit_interval\n+                            and self._accumulated)\n+                        or len(self._accumulated) >= self.cfg.buffer_threshold\n+                    )\n \n                 current_update_visible = False\n                 if should_edit and self._accumulated:"]},{"file":"tests/gateway/test_matrix.py","hunks":["@@ -108,6 +108,9 @@ def __init__(self, mxid=None, device_id=None, api=None,\n         def add_event_handler(self, event_type, handler):\n             self._event_handlers.setdefault(event_type, []).append(handler)\n \n+        def add_dispatcher(self, dispatcher_type):\n+            pass\n+\n     class InternalEventType:\n         INVITE = \"internal.invite\"\n ","@@ -115,6 +118,14 @@ class InternalEventType:\n     mautrix_client.InternalEventType = InternalEventType\n     mautrix.client = mautrix_client\n \n+    # --- mautrix.client.dispatcher ---\n+    mautrix_client_dispatcher = types.ModuleType(\"mautrix.client.dispatcher\")\n+\n+    class MembershipEventDispatcher:\n+        pass\n+\n+    mautrix_client_dispatcher.MembershipEventDispatcher = MembershipEventDispatcher\n+\n     # --- mautrix.client.state_store ---\n     mautrix_client_state_store = types.ModuleType(\"mautrix.client.state_store\")\n ","@@ -163,6 +174,19 @@ def __init__(self, account_id=\"\", pickle_key=\"\"):  # noqa: S301\n \n     mautrix_crypto_store.MemoryCryptoStore = MemoryCryptoStore\n \n+    # --- mautrix.crypto.attachments ---\n+    mautrix_crypto_attachments = types.ModuleType(\"mautrix.crypto.attachments\")\n+\n+    def encrypt_attachment(data):\n+        encrypted_file = MagicMock()\n+        encrypted_file.serialize.return_value = {\n+            \"key\": {\"k\": \"testkey\"}, \"iv\": \"testiv\",\n+            \"hashes\": {\"sha256\": \"testhash\"}, \"v\": \"v2\",\n+        }\n+        return (b\"ciphertext_\" + data, encrypted_file)\n+\n+    mautrix_crypto_attachments.encrypt_attachment = encrypt_attachment\n+\n     # --- mautrix.crypto.store.asyncpg ---\n     mautrix_crypto_store_asyncpg = types.ModuleType(\"mautrix.crypto.store.asyncpg\")\n ","@@ -200,8 +224,10 @@ def create(cls, url, upgrade_table=None):\n         \"mautrix.api\": mautrix_api,\n         \"mautrix.types\": mautrix_types,\n         \"mautrix.client\": mautrix_client,\n+        \"mautrix.client.dispatcher\": mautrix_client_dispatcher,\n         \"mautrix.client.state_store\": mautrix_client_state_store,\n         \"mautrix.crypto\": mautrix_crypto,\n+        \"mautrix.crypto.attachments\": mautrix_crypto_attachments,\n         \"mautrix.crypto.store\": mautrix_crypto_store,\n         \"mautrix.crypto.store.asyncpg\": mautrix_crypto_store_asyncpg,\n         \"mautrix.util\": mautrix_util,","@@ -357,6 +383,16 @@ async def test_stop_typing_clears_matrix_typing_state(self):\n             timeout=0,\n         )\n \n+    @pytest.mark.asyncio\n+    async def test_stop_typing_no_client_is_noop(self):\n+        self.adapter._client = None\n+        await self.adapter.stop_typing(\"!room:example.org\")  # should not raise\n+\n+    @pytest.mark.asyncio\n+    async def test_stop_typing_suppresses_exceptions(self):\n+        self.adapter._client.set_typing = AsyncMock(side_effect=Exception(\"network\"))\n+        await self.adapter.stop_typing(\"!room:example.org\")  # should not raise\n+\n \n # ---------------------------------------------------------------------------\n # mxc:// URL conversion","@@ -835,6 +871,41 @@ def __init__(self, user_id, device_id):\n         await adapter.disconnect()\n \n \n+class TestDeviceKeyReVerification:\n+    @pytest.mark.asyncio\n+    async def test_verify_fails_when_server_keys_mismatch_after_upload(self):\n+        \"\"\"share_keys() succeeds but server still has old keys -> should return False.\"\"\"\n+        adapter = _make_adapter()\n+\n+        mock_client = MagicMock()\n+        mock_client.mxid = \"@bot:example.org\"\n+        mock_client.device_id = \"TESTDEVICE\"\n+\n+        # First query: keys missing -> triggers share_keys\n+        # Second query: keys still don't match -> should fail\n+        mock_keys_missing = MagicMock()\n+        mock_keys_missing.device_keys = {\"@bot:example.org\": {}}\n+\n+        mock_keys_mismatch = MagicMock()\n+        mock_device = MagicMock()\n+        mock_device.keys = {\"ed25519:TESTDEVICE\": \"server_old_key\"}\n+        mock_keys_mismatch.device_keys = {\"@bot:example.org\": {\"TESTDEVICE\": mock_device}}\n+\n+        mock_client.query_keys = AsyncMock(side_effect=[mock_keys_missing, mock_keys_mismatch])\n+\n+        mock_olm = MagicMock()\n+        mock_olm.account = MagicMock()\n+        mock_olm.account.shared = False\n+        mock_olm.account.identity_keys = {\"ed25519\": \"local_new_key\"}\n+        mock_olm.share_keys = AsyncMock()\n+\n+        from gateway.platforms.matrix import MatrixAdapter\n+        result = await adapter._verify_device_keys_on_server(mock_client, mock_olm)\n+\n+        assert result is False\n+        mock_olm.share_keys.assert_awaited_once()\n+\n+\n class TestMatrixE2EEHardFail:\n     \"\"\"connect() must refuse to start when E2EE is requested but deps are missing.\"\"\"\n ","@@ -1139,6 +1210,56 @@ async def _sync_once(**kwargs):\n         mock_sync_store.put_next_batch.assert_awaited_once_with(\"s1234\")\n \n \n+class TestMatrixUploadAndSend:\n+    @pytest.mark.asyncio\n+    async def test_upload_unencrypted_room_uses_plain_url(self):\n+        \"\"\"Unencrypted rooms should use plain 'url' key.\"\"\"\n+        adapter = _make_adapter()\n+        adapter._encryption = True\n+        mock_client = MagicMock()\n+        mock_client.crypto = object()\n+        mock_client.state_store = MagicMock()\n+        mock_client.state_store.is_encrypted = AsyncMock(return_value=False)\n+        mock_client.upload_media = AsyncMock(return_value=\"mxc://example.org/plain\")\n+        mock_client.send_message_event = AsyncMock(return_value=\"$event\")\n+        adapter._client = mock_client\n+\n+        result = await adapter._upload_and_send(\n+            \"!room:example.org\", b\"hello\", \"test.txt\", \"text/plain\", \"m.file\",\n+        )\n+\n+        assert result.success is True\n+        sent = mock_client.send_message_event.await_args.args[2]\n+        assert sent[\"url\"] == \"mxc://example.org/plain\"\n+        assert \"file\" not in sent\n+\n+    @pytest.mark.asyncio\n+    async def test_upload_encrypted_room_uses_file_payload(self):\n+        \"\"\"Encrypted rooms should use 'file' key with crypto metadata.\"\"\"\n+        adapter = _make_adapter()\n+        adapter._encryption = True\n+        mock_client = MagicMock()\n+        mock_client.crypto = object()\n+        mock_client.state_store = MagicMock()\n+        mock_client.state_store.is_encrypted = AsyncMock(return_value=True)\n+        mock_client.upload_media = AsyncMock(return_value=\"mxc://example.org/enc\")\n+        mock_client.send_message_event = AsyncMock(return_value=\"$event\")\n+        adapter._client = mock_client\n+\n+        result = await adapter._upload_and_send(\n+            \"!room:example.org\", b\"secret\", \"secret.txt\", \"text/plain\", \"m.file\",\n+        )\n+\n+        assert result.success is True\n+        # Should have uploaded ciphertext, not plaintext\n+        uploaded_data = mock_client.upload_media.await_args.args[0]\n+        assert uploaded_data != b\"secret\"\n+        sent = mock_client.send_message_event.await_args.args[2]\n+        assert \"url\" not in sent\n+        assert \"file\" in sent\n+        assert sent[\"file\"][\"url\"] == \"mxc://example.org/enc\"\n+\n+\n class TestMatrixEncryptedSendFallback:\n     @pytest.mark.asyncio\n     async def test_send_retries_after_e2ee_error(self):","@@ -1165,128 +1286,24 @@ async def test_send_retries_after_e2ee_error(self):\n \n \n # ---------------------------------------------------------------------------\n-# E2EE: MegolmEvent key request + buffering via _on_encrypted_event\n+# E2EE: _joined_rooms reference preservation for CryptoStateStore\n # ---------------------------------------------------------------------------\n \n-class TestMatrixMegolmEventHandling:\n-    @pytest.mark.asyncio\n-    async def test_encrypted_event_buffers_for_retry(self):\n-        \"\"\"_on_encrypted_event should buffer undecrypted events for retry.\"\"\"\n-        adapter = _make_adapter()\n-        adapter._user_id = \"@bot:example.org\"\n-        adapter._startup_ts = 0.0\n-        adapter._dm_rooms = {}\n-\n-        fake_event = MagicMock()\n-        fake_event.room_id = \"!room:example.org\"\n-        fake_event.event_id = \"$encrypted_event\"\n-        fake_event.sender = \"@alice:example.org\"\n-\n-        await adapter._on_encrypted_event(fake_event)\n-\n-        # Should have buffered the event\n-        assert len(adapter._pending_megolm) == 1\n-        room_id, event, ts = adapter._pending_megolm[0]\n-        assert room_id == \"!room:example.org\"\n-        assert event is fake_event\n-\n-    @pytest.mark.asyncio\n-    async def test_encrypted_event_buffer_capped(self):\n-        \"\"\"Buffer should not grow past _MAX_PENDING_EVENTS.\"\"\"\n-        adapter = _make_adapter()\n-        adapter._user_id = \"@bot:example.org\"\n-        adapter._startup_ts = 0.0\n-        adapter._dm_rooms = {}\n-\n-        from gateway.platforms.matrix import _MAX_PENDING_EVENTS\n-\n-        for i in range(_MAX_PENDING_EVENTS + 10):\n-            evt = MagicMock()\n-            evt.room_id = \"!room:example.org\"\n-            evt.event_id = f\"$event_{i}\"\n-            evt.sender = \"@alice:example.org\"\n-            await adapter._on_encrypted_event(evt)\n-\n-        assert len(adapter._pending_megolm) == _MAX_PENDING_EVENTS\n-\n-\n-# ---------------------------------------------------------------------------\n-# E2EE: Retry pending decryptions\n-# ---------------------------------------------------------------------------\n-\n-class TestMatrixRetryPendingDecryptions:\n-    @pytest.mark.asyncio\n-    async def test_successful_decryption_routes_to_handler(self):\n-        adapter = _make_adapter()\n-        adapter._user_id = \"@bot:example.org\"\n-        adapter._startup_ts = 0.0\n-        adapter._dm_rooms = {}\n-\n-        fake_encrypted = MagicMock()\n-        fake_encrypted.event_id = \"$encrypted\"\n-\n-        decrypted_event = MagicMock()\n-\n-        mock_crypto = MagicMock()\n-        mock_crypto.decrypt_megolm_event = AsyncMock(return_value=decrypted_event)\n-\n-        fake_client = MagicMock()\n-        fake_client.crypto = mock_crypto\n-        adapter._client = fake_client\n-\n-        now = time.time()\n-        adapter._pending_megolm = [(\"!room:ex.org\", fake_encrypted, now)]\n-\n-        with patch.object(adapter, \"_on_room_message\", AsyncMock()) as mock_handler:\n-            await adapter._retry_pending_decryptions()\n-            mock_handler.assert_awaited_once_with(decrypted_event)\n-\n-        # Buffer should be empty now\n-        assert len(adapter._pending_megolm) == 0\n-\n-    @pytest.mark.asyncio\n-    async def test_still_undecryptable_stays_in_buffer(self):\n-        adapter = _make_adapter()\n-\n-        fake_encrypted = MagicMock()\n-        fake_encrypted.event_id = \"$still_encrypted\"\n-\n-        mock_crypto = MagicMock()\n-        mock_crypto.decrypt_megolm_event = AsyncMock(side_effect=Exception(\"missing key\"))\n-\n-        fake_client = MagicMock()\n-        fake_client.crypto = mock_crypto\n-        adapter._client = fake_client\n-\n-        now = time.time()\n-        adapter._pending_megolm = [(\"!room:ex.org\", fake_encrypted, now)]\n-\n-        await adapter._retry_pending_decryptions()\n-\n-        assert len(adapter._pending_megolm) == 1\n-\n-    @pytest.mark.asyncio\n-    async def test_expired_events_dropped(self):\n-        adapter = _make_adapter()\n-\n-        from gateway.platforms.matrix import _PENDING_EVENT_TTL\n-\n-        fake_event = MagicMock()\n-        fake_event.event_id = \"$old_event\"\n-\n-        mock_crypto = MagicMock()\n-        fake_client = MagicMock()\n-        fake_client.crypto = mock_crypto\n-        adapter._client = fake_client\n+class TestJoinedRoomsReference:\n+    def test_joined_rooms_reference_preserved_after_reassignment(self):\n+        \"\"\"_CryptoStateStore must see updates after initial sync populates rooms.\"\"\"\n+        from gateway.platforms.matrix import _CryptoStateStore\n \n-        # Timestamp well past TTL\n-        old_ts = time.time() - _PENDING_EVENT_TTL - 60\n-        adapter._pending_megolm = [(\"!room:ex.org\", fake_event, old_ts)]\n+        joined = set()\n+        store = _CryptoStateStore(MagicMock(), joined)\n \n-        await adapter._retry_pending_decryptions()\n+        # Simulate what connect() should do: mutate in place, not reassign.\n+        joined.clear()\n+        joined.update([\"!room1:example.org\", \"!room2:example.org\"])\n \n-        # Should have been dropped\n-        assert len(adapter._pending_megolm) == 0\n+        import asyncio\n+        rooms = asyncio.get_event_loop().run_until_complete(store.find_shared_rooms(\"@user:ex\"))\n+        assert set(rooms) == {\"!room1:example.org\", \"!room2:example.org\"}\n \n \n # ---------------------------------------------------------------------------","@@ -1354,11 +1371,70 @@ async def test_connect_registers_encrypted_event_handler_when_encryption_on(self\n         handler_calls = mock_client.add_event_handler.call_args_list\n         registered_types = [call.args[0] for call in handler_calls]\n \n-        # Should have registered handlers for ROOM_MESSAGE, REACTION, INVITE, and ROOM_ENCRYPTED\n-        assert len(handler_calls) >= 4  # At minimum these four\n+        # Should have registered handlers for ROOM_MESSAGE, REACTION, INVITE\n+        assert len(handler_calls) >= 3\n \n         await adapter.disconnect()\n \n+    @pytest.mark.asyncio\n+    async def test_connect_fails_on_stale_otk_conflict(self):\n+        \"\"\"connect() must refuse E2EE when OTK upload hits 'already exists'.\"\"\"\n+        from gateway.platforms.matrix import MatrixAdapter\n+\n+        config = PlatformConfig(\n+            enabled=True,\n+            token=\"[redacted:secret-assignment]\",\n+            extra={\n+                \"homeserver\": \"https://matrix.example.org\",\n+                \"user_id\": \"@bot:example.org\",\n+                \"encryption\": True,\n+            },\n+        )\n+        adapter = MatrixAdapter(config)\n+\n+        fake_mautrix_mods = _make_fake_mautrix()\n+\n+        mock_client = MagicMock()\n+        mock_client.mxid = \"@bot:example.org\"\n+        mock_client.device_id = None\n+        mock_client.state_store = MagicMock()\n+        mock_client.sync_store = MagicMock()\n+        mock_client.crypto = None\n+        mock_client.whoami = AsyncMock(return_value=MagicMock(user_id=\"@bot:example.org\", device_id=\"DEV123\"))\n+        mock_client.add_event_handler = MagicMock()\n+        mock_client.add_dispatcher = MagicMock()\n+        mock_client.query_keys = AsyncMock(return_value={\n+            \"device_keys\": {\"@bot:example.org\": {\"DEV123\": {\n+                \"keys\": {\"ed25519:DEV123\": \"fake_ed25519_key\"},\n+            }}},\n+        })\n+        mock_client.api = MagicMock()\n+        mock_client.api.token = \"[redacted:secret-assignment]\"\n+        mock_client.api.session = MagicMock()\n+        mock_client.api.session.close = AsyncMock()\n+\n+        # share_keys succeeds on first call (from _verify_device_keys_on_server),\n+        # then raises \"already exists\" on the proactive OTK flush in connect().\n+        mock_olm = MagicMock()\n+        mock_olm.load = AsyncMock()\n+        mock_olm.share_keys = AsyncMock(\n+            side_effect=[None, Exception(\"One time key signed_curve25519:AAAAAQ already exists\")]\n+        )\n+        mock_olm.share_keys_min_trust = None\n+        mock_olm.send_keys_min_trust = None\n+        mock_olm.account = MagicMock()\n+        mock_olm.account.identity_keys = {\"ed25519\": \"fake_ed25519_key\"}\n+\n+        fake_mautrix_mods[\"mautrix.client\"].Client = MagicMock(return_value=mock_client)\n+        fake_mautrix_mods[\"mautrix.crypto\"].OlmMachine = MagicMock(return_value=mock_olm)\n+\n+        from gateway.platforms import matrix as matrix_mod\n+        with patch.object(matrix_mod, \"_check_e2ee_deps\", return_value=True):\n+            with patch.dict(\"sys.modules\", fake_mautrix_mods):\n+                result = await adapter.connect()\n+\n+        assert result is False\n+\n \n # ---------------------------------------------------------------------------\n # Disconnect"]},{"file":"tests/gateway/test_matrix_mention.py","hunks":["@@ -10,7 +10,6 @@\n \n from gateway.config import PlatformConfig\n \n-\n # The matrix adapter module is importable without mautrix installed\n # (module-level imports use try/except with stubs).  No need for\n # module-level mock installation — tests that call adapter methods","@@ -159,9 +158,15 @@ def test_strip_full_user_id(self):\n         result = self.adapter._strip_mention(\"@hermes:example.org help me\")\n         assert result == \"help me\"\n \n-    def test_strip_localpart(self):\n+    def test_localpart_preserved(self):\n+        \"\"\"Localpart-only text is no longer stripped — avoids false positives in paths.\"\"\"\n         result = self.adapter._strip_mention(\"hermes help me\")\n-        assert result == \"help me\"\n+        assert result == \"hermes help me\"\n+\n+    def test_localpart_in_path_preserved(self):\n+        \"\"\"Localpart inside a file path must not be damaged.\"\"\"\n+        result = self.adapter._strip_mention(\"read /home/hermes/config.yaml\")\n+        assert result == \"read /home/hermes/config.yaml\"\n \n     def test_strip_returns_empty_for_mention_only(self):\n         result = self.adapter._strip_mention(\"@hermes:example.org\")","@@ -273,8 +278,8 @@ async def test_require_mention_dm_always_responds(monkeypatch):\n \n \n @pytest.mark.asyncio\n-async def test_dm_strips_mention(monkeypatch):\n-    \"\"\"DMs strip mention from body, matching Discord behavior.\"\"\"\n+async def test_dm_strips_full_mxid(monkeypatch):\n+    \"\"\"DMs strip the full MXID from body when require_mention is on (default).\"\"\"\n     monkeypatch.delenv(\"MATRIX_REQUIRE_MENTION\", raising=False)\n     monkeypatch.delenv(\"MATRIX_FREE_RESPONSE_ROOMS\", raising=False)\n     monkeypatch.setenv(\"MATRIX_AUTO_THREAD\", \"false\")","@@ -289,6 +294,23 @@ async def test_dm_strips_mention(monkeypatch):\n     assert msg.text == \"help me\"\n \n \n[redacted:email]\n+async def test_dm_preserves_localpart_in_body(monkeypatch):\n+    \"\"\"DMs no longer strip bare localpart — only the full MXID is removed.\"\"\"\n+    monkeypatch.delenv(\"MATRIX_REQUIRE_MENTION\", raising=False)\n+    monkeypatch.delenv(\"MATRIX_FREE_RESPONSE_ROOMS\", raising=False)\n+    monkeypatch.setenv(\"MATRIX_AUTO_THREAD\", \"false\")\n+\n+    adapter = _make_adapter()\n+    _set_dm(adapter)\n+    event = _make_event(\"hermes help me\")\n+\n+    await adapter._on_room_message(event)\n+    adapter.handle_message.assert_awaited_once()\n+    msg = adapter.handle_message.await_args.args[0]\n+    assert msg.text == \"hermes help me\"\n+\n+\n @pytest.mark.asyncio\n async def test_bare_mention_passes_empty_string(monkeypatch):\n     \"\"\"A message that is only a mention should pass through as empty, not be dropped.\"\"\"","@@ -309,7 +331,9 @@ async def test_bare_mention_passes_empty_string(monkeypatch):\n async def test_require_mention_free_response_room(monkeypatch):\n     \"\"\"Free-response rooms bypass mention requirement.\"\"\"\n     monkeypatch.delenv(\"MATRIX_REQUIRE_MENTION\", raising=False)\n-    monkeypatch.setenv(\"MATRIX_FREE_RESPONSE_ROOMS\", \"!room1:example.org,!room2:example.org\")\n+    monkeypatch.setenv(\n+        \"MATRIX_FREE_RESPONSE_ROOMS\", \"!room1:example.org,!room2:example.org\"\n+    )\n     monkeypatch.setenv(\"MATRIX_AUTO_THREAD\", \"false\")\n \n     adapter = _make_adapter()","@@ -351,6 +375,22 @@ async def test_require_mention_disabled(monkeypatch):\n     assert msg.text == \"hello without mention\"\n \n \n[redacted:email]\n+async def test_require_mention_disabled_skips_stripping(monkeypatch):\n+    \"\"\"MATRIX_REQUIRE_MENTION=false: mention text is NOT stripped from body.\"\"\"\n+    monkeypatch.setenv(\"MATRIX_REQUIRE_MENTION\", \"false\")\n+    monkeypatch.delenv(\"MATRIX_FREE_RESPONSE_ROOMS\", raising=False)\n+    monkeypatch.setenv(\"MATRIX_AUTO_THREAD\", \"false\")\n+\n+    adapter = _make_adapter()\n+    event = _make_event(\"@hermes:example.org help me\")\n+\n+    await adapter._on_room_message(event)\n+    adapter.handle_message.assert_awaited_once()\n+    msg = adapter.handle_message.await_args.args[0]\n+    assert msg.text == \"@hermes:example.org help me\"\n+\n+\n # ---------------------------------------------------------------------------\n # Auto-thread in _on_room_message\n # ---------------------------------------------------------------------------","@@ -442,8 +482,10 @@ class TestThreadPersistence:\n     def test_empty_state_file(self, tmp_path, monkeypatch):\n         \"\"\"No state file → empty set.\"\"\"\n         from gateway.platforms.helpers import ThreadParticipationTracker\n+\n         monkeypatch.setattr(\n-            ThreadParticipationTracker, \"_state_path\",\n+            ThreadParticipationTracker,\n+            \"_state_path\",\n             lambda self: tmp_path / \"matrix_threads.json\",\n         )\n         adapter = _make_adapter()","@@ -452,9 +494,11 @@ def test_empty_state_file(self, tmp_path, monkeypatch):\n     def test_track_thread_persists(self, tmp_path, monkeypatch):\n         \"\"\"mark() writes to disk.\"\"\"\n         from gateway.platforms.helpers import ThreadParticipationTracker\n+\n         state_path = tmp_path / \"matrix_threads.json\"\n         monkeypatch.setattr(\n-            ThreadParticipationTracker, \"_state_path\",\n+            ThreadParticipationTracker,\n+            \"_state_path\",\n             lambda self: state_path,\n         )\n         adapter = _make_adapter()","@@ -466,10 +510,12 @@ def test_track_thread_persists(self, tmp_path, monkeypatch):\n     def test_threads_survive_reload(self, tmp_path, monkeypatch):\n         \"\"\"Persisted threads are loaded by a new adapter instance.\"\"\"\n         from gateway.platforms.helpers import ThreadParticipationTracker\n+\n         state_path = tmp_path / \"matrix_threads.json\"\n         state_path.write_text(json.dumps([\"$t1\", \"$t2\"]))\n         monkeypatch.setattr(\n-            ThreadParticipationTracker, \"_state_path\",\n+            ThreadParticipationTracker,\n+            \"_state_path\",\n             lambda self: state_path,\n         )\n         adapter = _make_adapter()","@@ -479,9 +525,11 @@ def test_threads_survive_reload(self, tmp_path, monkeypatch):\n     def test_cap_max_tracked_threads(self, tmp_path, monkeypatch):\n         \"\"\"Thread set is trimmed to max_tracked.\"\"\"\n         from gateway.platforms.helpers import ThreadParticipationTracker\n+\n         state_path = tmp_path / \"matrix_threads.json\"\n         monkeypatch.setattr(\n-            ThreadParticipationTracker, \"_state_path\",\n+            ThreadParticipationTracker,\n+            \"_state_path\",\n             lambda self: state_path,\n         )\n         adapter = _make_adapter()","@@ -604,6 +652,7 @@ def test_yaml_bridge_sets_env_vars(self, monkeypatch, tmp_path):\n         }\n \n         import os\n+\n         import yaml\n \n         config_file = tmp_path / \"config.yaml\"","@@ -613,18 +662,27 @@ def test_yaml_bridge_sets_env_vars(self, monkeypatch, tmp_path):\n         yaml_cfg = yaml.safe_load(config_file.read_text())\n         matrix_cfg = yaml_cfg.get(\"matrix\", {})\n         if isinstance(matrix_cfg, dict):\n-            if \"require_mention\" in matrix_cfg and not os.getenv(\"MATRIX_REQUIRE_MENTION\"):\n-                monkeypatch.setenv(\"MATRIX_REQUIRE_MENTION\", str(matrix_cfg[\"require_mention\"]).lower())\n+            if \"require_mention\" in matrix_cfg and not os.getenv(\n+                \"MATRIX_REQUIRE_MENTION\"\n+            ):\n+                monkeypatch.setenv(\n+                    \"MATRIX_REQUIRE_MENTION\", str(matrix_cfg[\"require_mention\"]).lower()\n+                )\n             frc = matrix_cfg.get(\"free_response_rooms\")\n             if frc is not None and not os.getenv(\"MATRIX_FREE_RESPONSE_ROOMS\"):\n                 if isinstance(frc, list):\n                     frc = \",\".join(str(v) for v in frc)\n                 monkeypatch.setenv(\"MATRIX_FREE_RESPONSE_ROOMS\", str(frc))\n             if \"auto_thread\" in matrix_cfg and not os.getenv(\"MATRIX_AUTO_THREAD\"):\n-                monkeypatch.setenv(\"MATRIX_AUTO_THREAD\", str(matrix_cfg[\"auto_thread\"]).lower())\n+                monkeypatch.setenv(\n+                    \"MATRIX_AUTO_THREAD\", str(matrix_cfg[\"auto_thread\"]).lower()\n+                )\n \n         assert os.getenv(\"MATRIX_REQUIRE_MENTION\") == \"false\"\n-        assert os.getenv(\"MATRIX_FREE_RESPONSE_ROOMS\") == \"!room1:example.org,!room2:example.org\"\n+        assert (\n+            os.getenv(\"MATRIX_FREE_RESPONSE_ROOMS\")\n+            == \"!room1:example.org,!room2:example.org\"\n+        )\n         assert os.getenv(\"MATRIX_AUTO_THREAD\") == \"false\"\n \n     def test_yaml_bridge_sets_dm_mention_threads(self, monkeypatch, tmp_path):","@@ -632,6 +690,7 @@ def test_yaml_bridge_sets_dm_mention_threads(self, monkeypatch, tmp_path):\n         monkeypatch.delenv(\"MATRIX_DM_MENTION_THREADS\", raising=False)\n \n         import os\n+\n         import yaml\n \n         yaml_content = {\"matrix\": {\"dm_mention_threads\": True}}","@@ -641,8 +700,13 @@ def test_yaml_bridge_sets_dm_mention_threads(self, monkeypatch, tmp_path):\n         yaml_cfg = yaml.safe_load(config_file.read_text())\n         matrix_cfg = yaml_cfg.get(\"matrix\", {})\n         if isinstance(matrix_cfg, dict):\n-            if \"dm_mention_threads\" in matrix_cfg and not os.getenv(\"MATRIX_DM_MENTION_THREADS\"):\n-                monkeypatch.setenv(\"MATRIX_DM_MENTION_THREADS\", str(matrix_cfg[\"dm_mention_threads\"]).lower())\n+            if \"dm_mention_threads\" in matrix_cfg and not os.getenv(\n+                \"MATRIX_DM_MENTION_THREADS\"\n+            ):\n+                monkeypatch.setenv(\n+                    \"MATRIX_DM_MENTION_THREADS\",\n+                    str(matrix_cfg[\"dm_mention_threads\"]).lower(),\n+                )\n \n         assert os.getenv(\"MATRIX_DM_MENTION_THREADS\") == \"true\"\n ","@@ -651,9 +715,12 @@ def test_env_vars_take_precedence_over_yaml(self, monkeypatch):\n         monkeypatch.setenv(\"MATRIX_REQUIRE_MENTION\", \"true\")\n \n         import os\n+\n         yaml_cfg = {\"matrix\": {\"require_mention\": False}}\n         matrix_cfg = yaml_cfg.get(\"matrix\", {})\n         if \"require_mention\" in matrix_cfg and not os.getenv(\"MATRIX_REQUIRE_MENTION\"):\n-            monkeypatch.setenv(\"MATRIX_REQUIRE_MENTION\", str(matrix_cfg[\"require_mention\"]).lower())\n+            monkeypatch.setenv(\n+                \"MATRIX_REQUIRE_MENTION\", str(matrix_cfg[\"require_mention\"]).lower()\n+            )\n \n         assert os.getenv(\"MATRIX_REQUIRE_MENTION\") == \"true\""]},{"file":"tests/gateway/test_stream_consumer.py","hunks":["@@ -963,3 +963,106 @@ async def test_think_block_not_sent_to_platform(self):\n             await task\n         except asyncio.CancelledError:\n             pass\n+\n+\n+# ── buffer_only mode tests ─────────────────────────────────────────────\n+\n+\n+class TestBufferOnlyMode:\n+    \"\"\"Verify buffer_only mode suppresses intermediate edits and only\n+    flushes on structural boundaries (done, segment break, commentary).\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_suppresses_intermediate_edits(self):\n+        \"\"\"Time-based and size-based edits are skipped; only got_done flushes.\"\"\"\n+        adapter = MagicMock()\n+        adapter.MAX_MESSAGE_LENGTH = 4096\n+        adapter.send = AsyncMock(return_value=SimpleNamespace(success=True, message_id=\"msg1\"))\n+        adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=True))\n+\n+        cfg = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5, cursor=\"\", buffer_only=True)\n+        consumer = GatewayStreamConsumer(adapter, \"!room:server\", config=cfg)\n+\n+        for word in [\"Hello\", \" world\", \", this\", \" is\", \" a\", \" test\"]:\n+            consumer.on_delta(word)\n+        consumer.finish()\n+\n+        await consumer.run()\n+\n+        adapter.send.assert_called_once()\n+        adapter.edit_message.assert_not_called()\n+        assert \"Hello world, this is a test\" in adapter.send.call_args_list[0][1][\"content\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_flushes_on_segment_break(self):\n+        \"\"\"A segment break (tool call boundary) flushes accumulated text.\"\"\"\n+        adapter = MagicMock()\n+        adapter.MAX_MESSAGE_LENGTH = 4096\n+        adapter.send = AsyncMock(side_effect=[\n+            SimpleNamespace(success=True, message_id=\"msg1\"),\n+            SimpleNamespace(success=True, message_id=\"msg2\"),\n+        ])\n+        adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=True))\n+\n+        cfg = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5, cursor=\"\", buffer_only=True)\n+        consumer = GatewayStreamConsumer(adapter, \"!room:server\", config=cfg)\n+\n+        consumer.on_delta(\"Before tool call\")\n+        consumer.on_delta(None)\n+        consumer.on_delta(\"After tool call\")\n+        consumer.finish()\n+\n+        await consumer.run()\n+\n+        assert adapter.send.call_count == 2\n+        assert \"Before tool call\" in adapter.send.call_args_list[0][1][\"content\"]\n+        assert \"After tool call\" in adapter.send.call_args_list[1][1][\"content\"]\n+        adapter.edit_message.assert_not_called()\n+\n+    @pytest.mark.asyncio\n+    async def test_flushes_on_commentary(self):\n+        \"\"\"An interim commentary message flushes in buffer_only mode.\"\"\"\n+        adapter = MagicMock()\n+        adapter.MAX_MESSAGE_LENGTH = 4096\n+        adapter.send = AsyncMock(side_effect=[\n+            SimpleNamespace(success=True, message_id=\"msg1\"),\n+            SimpleNamespace(success=True, message_id=\"msg2\"),\n+            SimpleNamespace(success=True, message_id=\"msg3\"),\n+        ])\n+        adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=True))\n+\n+        cfg = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5, cursor=\"\", buffer_only=True)\n+        consumer = GatewayStreamConsumer(adapter, \"!room:server\", config=cfg)\n+\n+        consumer.on_delta(\"Working on it...\")\n+        consumer.on_commentary(\"I'll search for that first.\")\n+        consumer.on_delta(\"Here are the results.\")\n+        consumer.finish()\n+\n+        await consumer.run()\n+\n+        # Three sends: accumulated text, commentary, final text\n+        assert adapter.send.call_count >= 2\n+        adapter.edit_message.assert_not_called()\n+\n+    @pytest.mark.asyncio\n+    async def test_default_mode_still_triggers_intermediate_edits(self):\n+        \"\"\"Regression: buffer_only=False (default) still does progressive edits.\"\"\"\n+        adapter = MagicMock()\n+        adapter.MAX_MESSAGE_LENGTH = 4096\n+        adapter.send = AsyncMock(return_value=SimpleNamespace(success=True, message_id=\"msg1\"))\n+        adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=True))\n+\n+        # buffer_threshold=5 means any 5+ chars triggers an early edit\n+        cfg = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5, cursor=\"\")\n+        consumer = GatewayStreamConsumer(adapter, \"!room:server\", config=cfg)\n+\n+        consumer.on_delta(\"Hello world, this is long enough to trigger edits\")\n+        consumer.finish()\n+\n+        await consumer.run()\n+\n+        # Should have at least one send. With buffer_threshold=5 and this much\n+        # text, the consumer may send then edit, or just send once at got_done.\n+        # The key assertion: this doesn't break.\n+        assert adapter.send.call_count >= 1"]},{"file":"website/docs/user-guide/messaging/matrix.md","hunks":["@@ -284,8 +284,40 @@ MATRIX_RECOVERY_KEY=EsT... your recovery key here\n \n 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.\n \n-:::warning\n-If you delete the `~/.hermes/platforms/matrix/store/` directory, the bot loses its encryption keys. You'll need to verify the device again in your Matrix client. Back up this directory if you want to preserve encrypted sessions.\n+:::warning[Deleting the crypto store]\n+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.\n+\n+Hermes detects this condition on startup and refuses to enable E2EE, logging: `device XXXX has stale one-time keys on the server signed with a previous identity key`.\n+\n+**Easiest recovery: generate a new access token** (which gets a fresh device ID with no stale key history). See the \"Upgrading from a previous version with E2EE\" section below. This is the most reliable path and avoids touching the homeserver database.\n+\n+**Manual recovery** (advanced — keeps the same device ID):\n+\n+1. Stop Synapse and delete the old device from its database:\n+   ```bash\n+   sudo systemctl stop matrix-synapse\n+   sudo sqlite3 /var/lib/matrix-synapse/homeserver.db \"\n+     DELETE FROM e2e_device_keys_json WHERE device_id = 'DEVICE_ID' AND user_id = '@hermes:your-server';\n+     DELETE FROM e2e_one_time_keys_json WHERE device_id = 'DEVICE_ID' AND user_id = '@hermes:your-server';\n+     DELETE FROM e2e_fallback_keys_json WHERE device_id = 'DEVICE_ID' AND user_id = '@hermes:your-server';\n+     DELETE FROM devices WHERE device_id = 'DEVICE_ID' AND user_id = '@hermes:your-server';\n+   \"\n+   sudo systemctl start matrix-synapse\n+   ```\n+   Or via the Synapse admin API (note the URL-encoded user ID):\n+   ```bash\n+   curl -X DELETE -H \"Authorization: Bearer ADMIN_TOKEN\" \\\n+     'https://your-server/_synapse/admin/v2/users/%40hermes%3Ayour-server/devices/DEVICE_ID'\n+   ```\n+   Note: deleting a device via the admin API may also invalidate the associated access token. You may need to generate a new token afterward.\n+\n+2. Delete the local crypto store and restart Hermes:\n+   ```bash\n+   rm -f ~/.hermes/platforms/matrix/store/crypto.db*\n+   # restart hermes\n+   ```\n+\n+Other Matrix clients (Element, matrix-commander) may cache the old device keys. After recovery, type `/discardsession` in Element to force a new encryption session with the bot.\n :::\n \n :::info","@@ -361,6 +393,10 @@ pip install 'hermes-agent[matrix]'\n \n ### Upgrading from a previous version with E2EE\n \n+:::tip\n+If you also manually deleted `crypto.db`, see the \"Deleting the crypto store\" warning in the E2EE section above — there are additional steps to clear stale one-time keys from the homeserver.\n+:::\n+\n If you previously used Hermes with `MATRIX_ENCRYPTION=true` and are upgrading to\n a version that uses the new SQLite-based crypto store, the bot's encryption\n identity has changed. Your Matrix client (Element) may cache the old device keys\n"]}]},{"kind":"pr","number":12803,"title":"fix(matrix): improve fresh-room detection and harden sync/timestamp handling","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#12614","number":12614},{"kind":"pr","value":"#27330","number":27330}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":16821,"title":"matrix: parity salvage batch (10 contributor PRs)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","gateway/platforms/matrix.py","gateway/run.py","hermes_cli/config.py","pyproject.toml","scripts/release.py","tests/e2e/matrix_xsign_bootstrap/README.md","tests/e2e/matrix_xsign_bootstrap/docker-compose.yml","tests/e2e/matrix_xsign_bootstrap/test_bootstrap.py","tests/gateway/test_matrix.py","tests/gateway/test_matrix_exec_approval.py","tests/gateway/test_matrix_mention.py","tests/gateway/test_platform_base.py"]},{"kind":"pr","number":27330,"title":"fix(matrix): warn on clock-skew silent message drops (#12614)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/matrix.py","tests/gateway/test_matrix.py","website/docs/user-guide/messaging/matrix.md"]},{"kind":"pr","number":37807,"title":"fix(gateway): start Matrix mautrix syncer","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#7914","number":7914},{"kind":"pr","value":"#18505","number":18505},{"kind":"pr","value":"#18505","number":18505}],"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":37991,"title":"fix(gateway): start Matrix client syncer","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#37807","number":37807},{"kind":"pr","value":"#7914","number":7914},{"kind":"pr","value":"#18505","number":18505}],"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":40313,"title":"fix(gateway): start matrix client syncer","url":null,"state":"closed","close_reason":{"category":"volume_quota","implication":"active"},"merged":false},{"kind":"pr","number":46365,"title":"fix(send_message): reuse live gateway adapter for Matrix media sends (#46310)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a7fd62d82","number":0},{"kind":"pr","value":"#54031","number":54031},{"kind":"pr","value":"#41112","number":41112},{"kind":"pr","value":"#46310","number":46310}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":46407,"title":"fix(matrix): reuse single adapter for multi-chunk media sends (#46310)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#54031","number":54031},{"kind":"pr","value":"#46310","number":46310},{"kind":"pr","value":"#46365","number":46365}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":47567,"title":"fix(matrix): await inbound sync handlers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4c2c54c78","number":0},{"kind":"pr","value":"#56345","number":56345}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":54031,"title":"fix(send_message): reuse live gateway adapter for Matrix media sends (#46310)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_send_message_tool.py","tools/send_message_tool.py"]},{"kind":"pr","number":56345,"title":"fix(matrix): await inbound sync handlers","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/platforms/matrix/adapter.py","scripts/release.py","tests/gateway/test_matrix.py"],"fix_hunks":[{"file":"plugins/platforms/matrix/adapter.py","hunks":["@@ -1459,9 +1459,21 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:\n         # Without this the INVITE handler below never fires.\n         client.add_dispatcher(MembershipEventDispatcher)\n \n-        client.add_event_handler(EventType.ROOM_MESSAGE, self._on_room_message)\n-        client.add_event_handler(EventType.REACTION, self._on_reaction)\n-        client.add_event_handler(IntEvt.INVITE, self._on_invite)\n+        client.add_event_handler(\n+            EventType.ROOM_MESSAGE,\n+            self._on_room_message,\n+            wait_sync=True,\n+        )\n+        client.add_event_handler(\n+            EventType.REACTION,\n+            self._on_reaction,\n+            wait_sync=True,\n+        )\n+        client.add_event_handler(\n+            IntEvt.INVITE,\n+            self._on_invite,\n+            wait_sync=True,\n+        )\n \n         # Initial sync to catch up, then start background sync.\n         self._startup_ts = time.time()"]},{"file":"scripts/release.py","hunks":["@@ -232,6 +232,7 @@\n     \"[redacted:email]\": \"synapsesx\",\n     \"[redacted:email]\": \"itsflownium\",\n     \"[redacted:email]\": \"dirtyren\",\n+    \"[redacted:email]\": \"yyzquwu\",  # PR #47567 salvage (Matrix: register inbound handlers with wait_sync=True so _dispatch_sync's gather awaits them; without it mautrix fire-and-forgets and inbound intake has no completion point)\n     \"[redacted:email]\": \"tgmerritt\",  # PR #43553 salvage (parse vLLM's token-based output-cap error format so over-cap max_tokens 400s reduce the output cap instead of death-looping into compression)\n     \"[redacted:email]\": \"justin-cyhuang\",\n     \"[redacted:email]\": \"Tranquil-Flow\","]},{"file":"tests/gateway/test_matrix.py","hunks":["@@ -106,7 +106,7 @@ def __init__(self, mxid=None, device_id=None, api=None,\n             self.crypto = None\n             self._event_handlers = {}\n \n-        def add_event_handler(self, event_type, handler):\n+        def add_event_handler(self, event_type, handler, **kwargs):\n             self._event_handlers.setdefault(event_type, []).append(handler)\n \n         def add_dispatcher(self, dispatcher_type):","@@ -2727,13 +2727,20 @@ async def test_connect_registers_encrypted_event_handler_when_encryption_on(self\n                     with patch.object(adapter, \"_sync_loop\", AsyncMock(return_value=None)):\n                         assert await adapter.connect() is True\n \n-        # Verify event handlers were registered.\n-        # In mautrix the order is: add_event_handler(EventType, callback)\n+        # Verify inbound event handlers were registered as sync-awaited\n+        # callbacks. mautrix only returns waited handler tasks from\n+        # handle_sync(), so background-only handlers leave _dispatch_sync()\n+        # without a completion point for Hermes' Matrix intake.\n         handler_calls = mock_client.add_event_handler.call_args_list\n-        registered_types = [call.args[0] for call in handler_calls]\n+        waited_types = {\n+            str(call.args[0])\n+            for call in handler_calls\n+            if call.kwargs.get(\"wait_sync\") is True\n+        }\n \n-        # Should have registered handlers for ROOM_MESSAGE, REACTION, INVITE\n-        assert len(handler_calls) >= 3\n+        assert \"m.room.message\" in waited_types\n+        assert \"m.reaction\" in waited_types\n+        assert \"internal.invite\" in waited_types\n \n         await adapter.disconnect()\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}