fix(matrix): send non-empty POST body on room join for Continuwuity compatibility (#5248)

This commit is contained in:
Orrin Witt 2026-08-06 06:29:57 -04:00 committed by GitHub
parent 223b911e7e
commit 5c4c2cb819
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 116 additions and 4 deletions

View File

@ -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)

View File

@ -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())