mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 01:48:53 +00:00
fix(matrix): send non-empty POST body on room join for Continuwuity compatibility (#5248)
This commit is contained in:
parent
223b911e7e
commit
5c4c2cb819
@ -24,10 +24,12 @@ try:
|
|||||||
import nh3
|
import nh3
|
||||||
from mistune import HTMLRenderer, create_markdown
|
from mistune import HTMLRenderer, create_markdown
|
||||||
from nio import (
|
from nio import (
|
||||||
|
Api,
|
||||||
AsyncClient,
|
AsyncClient,
|
||||||
AsyncClientConfig,
|
AsyncClientConfig,
|
||||||
InviteEvent,
|
InviteEvent,
|
||||||
JoinError,
|
JoinError,
|
||||||
|
JoinResponse,
|
||||||
KeyVerificationCancel,
|
KeyVerificationCancel,
|
||||||
KeyVerificationEvent,
|
KeyVerificationEvent,
|
||||||
KeyVerificationKey,
|
KeyVerificationKey,
|
||||||
@ -43,6 +45,7 @@ try:
|
|||||||
RoomSendResponse,
|
RoomSendResponse,
|
||||||
RoomTypingError,
|
RoomTypingError,
|
||||||
SyncError,
|
SyncError,
|
||||||
|
SyncResponse,
|
||||||
ToDeviceError,
|
ToDeviceError,
|
||||||
UploadError,
|
UploadError,
|
||||||
)
|
)
|
||||||
@ -701,6 +704,7 @@ class MatrixChannel(BaseChannel):
|
|||||||
client.add_response_callback(self._on_sync_error, SyncError)
|
client.add_response_callback(self._on_sync_error, SyncError)
|
||||||
client.add_response_callback(self._on_join_error, JoinError)
|
client.add_response_callback(self._on_join_error, JoinError)
|
||||||
client.add_response_callback(self._on_send_error, RoomSendError)
|
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:
|
def _is_sas_sender_allowed(self, sender: str) -> bool:
|
||||||
return bool(sender and self.is_allowed(sender))
|
return bool(sender and self.is_allowed(sender))
|
||||||
@ -782,6 +786,49 @@ class MatrixChannel(BaseChannel):
|
|||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
self.client.stop_sync_forever()
|
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:
|
async def _on_join_error(self, response: JoinError) -> None:
|
||||||
self._log_response_error("join", response)
|
self._log_response_error("join", response)
|
||||||
|
|
||||||
@ -838,8 +885,7 @@ class MatrixChannel(BaseChannel):
|
|||||||
|
|
||||||
async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None:
|
async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None:
|
||||||
if self.is_allowed(event.sender):
|
if self.is_allowed(event.sender):
|
||||||
client = self._require_client()
|
await self._join_room_safe(room.room_id)
|
||||||
await client.join(room.room_id)
|
|
||||||
|
|
||||||
def _is_direct_room(self, room: MatrixRoom) -> bool:
|
def _is_direct_room(self, room: MatrixRoom) -> bool:
|
||||||
count = getattr(room, "member_count", None)
|
count = getattr(room, "member_count", None)
|
||||||
|
|||||||
@ -4,13 +4,14 @@ import asyncio
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
pytest.importorskip("nio")
|
pytest.importorskip("nio")
|
||||||
pytest.importorskip("nh3")
|
pytest.importorskip("nh3")
|
||||||
pytest.importorskip("mistune")
|
pytest.importorskip("mistune")
|
||||||
from nio import RoomSendResponse, SyncError
|
from nio import JoinResponse, RoomSendResponse, SyncError
|
||||||
|
|
||||||
import nanobot.channels.matrix.runtime as matrix_module
|
import nanobot.channels.matrix.runtime as matrix_module
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
@ -104,6 +105,15 @@ class _FakeAsyncClient:
|
|||||||
async def join(self, room_id: str) -> None:
|
async def join(self, room_id: str) -> None:
|
||||||
self.join_calls.append(room_id)
|
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):
|
async def accept_key_verification(self, transaction_id: str):
|
||||||
self.operation_calls.append(f"accept:{transaction_id}")
|
self.operation_calls.append(f"accept:{transaction_id}")
|
||||||
self.accept_key_verification_calls.append(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 clients[0].load_store_called is False
|
||||||
assert len(clients[0].callbacks) == 3
|
assert len(clients[0].callbacks) == 3
|
||||||
assert clients[0].to_device_callbacks == []
|
assert clients[0].to_device_callbacks == []
|
||||||
assert len(clients[0].response_callbacks) == 3
|
assert len(clients[0].response_callbacks) == 4
|
||||||
|
|
||||||
await channel.stop()
|
await channel.stop()
|
||||||
|
|
||||||
@ -590,6 +600,7 @@ async def test_room_invite_joins_when_sender_allowed() -> None:
|
|||||||
|
|
||||||
assert client.join_calls == ["!room:matrix.org"]
|
assert client.join_calls == ["!room:matrix.org"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_room_invite_respects_allow_list_when_configured() -> None:
|
async def test_room_invite_respects_allow_list_when_configured() -> None:
|
||||||
channel = MatrixChannel(_make_config(allow_from=["@bob:matrix.org"]), MessageBus())
|
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 == []
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_on_message_sets_typing_for_allowed_sender() -> None:
|
async def test_on_message_sets_typing_for_allowed_sender() -> None:
|
||||||
channel = MatrixChannel(_make_config(), MessageBus())
|
channel = MatrixChannel(_make_config(), MessageBus())
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user