From 5c4c2cb8197c12d703f5bf7bd55aab6c7cd18b53 Mon Sep 17 00:00:00 2001 From: Orrin Witt Date: Thu, 6 Aug 2026 06:29:57 -0400 Subject: [PATCH] fix(matrix): send non-empty POST body on room join for Continuwuity compatibility (#5248) --- nanobot/channels/matrix/runtime.py | 50 ++++++++++++- .../matrix/tests/test_matrix_channel.py | 70 ++++++++++++++++++- 2 files changed, 116 insertions(+), 4 deletions(-) diff --git a/nanobot/channels/matrix/runtime.py b/nanobot/channels/matrix/runtime.py index 04f8b254f..70130bf57 100644 --- a/nanobot/channels/matrix/runtime.py +++ b/nanobot/channels/matrix/runtime.py @@ -24,10 +24,12 @@ try: import nh3 from mistune import HTMLRenderer, create_markdown from nio import ( + Api, AsyncClient, AsyncClientConfig, InviteEvent, JoinError, + JoinResponse, KeyVerificationCancel, KeyVerificationEvent, KeyVerificationKey, @@ -43,6 +45,7 @@ try: RoomSendResponse, RoomTypingError, SyncError, + SyncResponse, ToDeviceError, UploadError, ) @@ -701,6 +704,7 @@ class MatrixChannel(BaseChannel): client.add_response_callback(self._on_sync_error, SyncError) client.add_response_callback(self._on_join_error, JoinError) client.add_response_callback(self._on_send_error, RoomSendError) + client.add_response_callback(self._on_sync_invite_fallback, SyncResponse) def _is_sas_sender_allowed(self, sender: str) -> bool: return bool(sender and self.is_allowed(sender)) @@ -782,6 +786,49 @@ class MatrixChannel(BaseChannel): with suppress(Exception): self.client.stop_sync_forever() + async def _join_room_safe(self, room_id: str) -> bool: + """Join a room, sending a non-empty POST body. + + nio's ``Api.join()`` produces a POST with no body. Some homeservers + (notably Continuwuity) reject empty bodies with ``M_BAD_JSON``. + Sending ``"{}"`` satisfies both strict and lenient servers. + """ + client = self._require_client() + method, path = Api.join(client.access_token, room_id) + try: + resp = cast( + JoinResponse | JoinError, + await client._send( # type: ignore[reportPrivateUsage, reportUnknownMemberType] + JoinResponse, method, path, data="{}" + ), + ) + except Exception: + self.logger.error("Matrix join request exception for room={}", room_id, exc_info=True) + return False + if isinstance(resp, JoinError): + self.logger.error("Matrix auto-join failed for room={}: {}", room_id, resp) + return False + self.logger.info("Matrix auto-join succeeded: {}", room_id) + return True + + async def _on_sync_invite_fallback(self, response: SyncResponse) -> None: + """Safety net: join pending invites that the event callback may have missed. + + Some homeservers (e.g. Continuwuity) deliver each invite only once. + If ``_on_room_invite`` fires but the join fails, the sync token + advances and the invite is never re-delivered. This callback inspects + the same ``SyncResponse`` for pending invites and joins them, acting + as a fallback alongside the event-based callback. + """ + if not response.rooms or not response.rooms.invite: + return + for room_id, invite_info in response.rooms.invite.items(): + for event in cast(list[Any], invite_info.invite_state): + sender = getattr(event, "sender", None) + if sender and self.is_allowed(cast(str, sender)): + await self._join_room_safe(room_id) + break + async def _on_join_error(self, response: JoinError) -> None: self._log_response_error("join", response) @@ -838,8 +885,7 @@ class MatrixChannel(BaseChannel): async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None: if self.is_allowed(event.sender): - client = self._require_client() - await client.join(room.room_id) + await self._join_room_safe(room.room_id) def _is_direct_room(self, room: MatrixRoom) -> bool: count = getattr(room, "member_count", None) diff --git a/nanobot/channels/matrix/tests/test_matrix_channel.py b/nanobot/channels/matrix/tests/test_matrix_channel.py index bcc6f3af4..feef451a4 100644 --- a/nanobot/channels/matrix/tests/test_matrix_channel.py +++ b/nanobot/channels/matrix/tests/test_matrix_channel.py @@ -4,13 +4,14 @@ import asyncio import sys from pathlib import Path from types import SimpleNamespace +from urllib.parse import unquote import pytest pytest.importorskip("nio") pytest.importorskip("nh3") pytest.importorskip("mistune") -from nio import RoomSendResponse, SyncError +from nio import JoinResponse, RoomSendResponse, SyncError import nanobot.channels.matrix.runtime as matrix_module from nanobot.bus.events import OutboundMessage @@ -104,6 +105,15 @@ class _FakeAsyncClient: async def join(self, room_id: str) -> None: self.join_calls.append(room_id) + async def _send(self, response_class, method, path, data=None, **kwargs): + """Minimal mock for nio's ``_send`` used by ``_join_room_safe``.""" + if response_class is JoinResponse and method == "POST" and "/join/" in path: + encoded = path.split("/join/")[1].split("?")[0] + room_id = unquote(encoded) + self.join_calls.append(room_id) + return JoinResponse(room_id=room_id) + return response_class() + async def accept_key_verification(self, transaction_id: str): self.operation_calls.append(f"accept:{transaction_id}") self.accept_key_verification_calls.append(transaction_id) @@ -308,7 +318,7 @@ async def test_start_skips_load_store_when_device_id_missing( assert clients[0].load_store_called is False assert len(clients[0].callbacks) == 3 assert clients[0].to_device_callbacks == [] - assert len(clients[0].response_callbacks) == 3 + assert len(clients[0].response_callbacks) == 4 await channel.stop() @@ -590,6 +600,7 @@ async def test_room_invite_joins_when_sender_allowed() -> None: assert client.join_calls == ["!room:matrix.org"] + @pytest.mark.asyncio async def test_room_invite_respects_allow_list_when_configured() -> None: channel = MatrixChannel(_make_config(allow_from=["@bob:matrix.org"]), MessageBus()) @@ -604,6 +615,61 @@ async def test_room_invite_respects_allow_list_when_configured() -> None: assert client.join_calls == [] +@pytest.mark.asyncio +async def test_on_sync_invite_fallback_joins_pending_invites() -> None: + """_on_sync_invite_fallback joins rooms from sync invite_state for allowed senders.""" + channel = MatrixChannel( + _make_config(allow_from=["@alice:matrix.org"]), MessageBus() + ) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + invite_event = SimpleNamespace(sender="@alice:matrix.org") + invite_info = SimpleNamespace(invite_state=[invite_event]) + rooms = SimpleNamespace(invite={"!room:matrix.org": invite_info}) + response = SimpleNamespace(rooms=rooms) + + await channel._on_sync_invite_fallback(response) + + assert client.join_calls == ["!room:matrix.org"] + + +@pytest.mark.asyncio +async def test_on_sync_invite_fallback_skips_when_no_invites() -> None: + """_on_sync_invite_fallback is a no-op when sync has no invites.""" + channel = MatrixChannel( + _make_config(allow_from=["@alice:matrix.org"]), MessageBus() + ) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + rooms = SimpleNamespace(invite={}) + response = SimpleNamespace(rooms=rooms) + + await channel._on_sync_invite_fallback(response) + + assert client.join_calls == [] + + +@pytest.mark.asyncio +async def test_on_sync_invite_fallback_skips_denied_sender() -> None: + """_on_sync_invite_fallback respects the allow list.""" + channel = MatrixChannel( + _make_config(allow_from=["@bob:matrix.org"]), MessageBus() + ) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + invite_event = SimpleNamespace(sender="@alice:matrix.org") + invite_info = SimpleNamespace(invite_state=[invite_event]) + rooms = SimpleNamespace(invite={"!room:matrix.org": invite_info}) + response = SimpleNamespace(rooms=rooms) + + await channel._on_sync_invite_fallback(response) + + assert client.join_calls == [] + + @pytest.mark.asyncio async def test_on_message_sets_typing_for_allowed_sender() -> None: channel = MatrixChannel(_make_config(), MessageBus())