feat(matrix): reply in threads with fallback relations

Propagate Matrix thread metadata from inbound events and attach
m.relates_to
(rel_type=m.thread, m.in_reply_to, is_falling_back=true) to outbound
messages
including attachments. Add tests for thread metadata and thread replies.
This commit is contained in:
Alexander Minges
2026-02-12 17:39:17 +01:00
committed by Alexander Minges
parent 6a40665753
commit 705d5738e3
2 changed files with 199 additions and 10 deletions

View File

@@ -510,6 +510,43 @@ async def test_on_message_room_mention_requires_opt_in() -> None:
assert client.typing_calls == [("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)]
@pytest.mark.asyncio
async def test_on_message_sets_thread_metadata_when_threaded_event() -> None:
channel = MatrixChannel(_make_config(), MessageBus())
client = _FakeAsyncClient("", "", "", None)
channel.client = client
handled: list[dict[str, object]] = []
async def _fake_handle_message(**kwargs) -> None:
handled.append(kwargs)
channel._handle_message = _fake_handle_message # type: ignore[method-assign]
room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=3)
event = SimpleNamespace(
sender="@alice:matrix.org",
body="Hello",
event_id="$reply1",
source={
"content": {
"m.relates_to": {
"rel_type": "m.thread",
"event_id": "$root1",
}
}
},
)
await channel._on_message(room, event)
assert len(handled) == 1
metadata = handled[0]["metadata"]
assert metadata["thread_root_event_id"] == "$root1"
assert metadata["thread_reply_to_event_id"] == "$reply1"
assert metadata["event_id"] == "$reply1"
@pytest.mark.asyncio
async def test_on_media_message_downloads_attachment_and_sets_metadata(
monkeypatch, tmp_path
@@ -563,6 +600,51 @@ async def test_on_media_message_downloads_attachment_and_sets_metadata(
assert "[attachment: " in handled[0]["content"]
@pytest.mark.asyncio
async def test_on_media_message_sets_thread_metadata_when_threaded_event(
monkeypatch, tmp_path
) -> None:
monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path)
channel = MatrixChannel(_make_config(), MessageBus())
client = _FakeAsyncClient("", "", "", None)
client.download_bytes = b"image"
channel.client = client
handled: list[dict[str, object]] = []
async def _fake_handle_message(**kwargs) -> None:
handled.append(kwargs)
channel._handle_message = _fake_handle_message # type: ignore[method-assign]
room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=2)
event = SimpleNamespace(
sender="@alice:matrix.org",
body="photo.png",
url="mxc://example.org/mediaid",
event_id="$event1",
source={
"content": {
"msgtype": "m.image",
"info": {"mimetype": "image/png", "size": 5},
"m.relates_to": {
"rel_type": "m.thread",
"event_id": "$root1",
},
}
},
)
await channel._on_media_message(room, event)
assert len(handled) == 1
metadata = handled[0]["metadata"]
assert metadata["thread_root_event_id"] == "$root1"
assert metadata["thread_reply_to_event_id"] == "$event1"
assert metadata["event_id"] == "$event1"
@pytest.mark.asyncio
async def test_on_media_message_respects_declared_size_limit(
monkeypatch, tmp_path
@@ -801,6 +883,34 @@ async def test_send_uploads_media_and_sends_file_event(tmp_path) -> None:
assert client.room_send_calls[1]["content"]["body"] == "Please review."
@pytest.mark.asyncio
async def test_send_adds_thread_relates_to_for_thread_metadata() -> None:
channel = MatrixChannel(_make_config(), MessageBus())
client = _FakeAsyncClient("", "", "", None)
channel.client = client
metadata = {
"thread_root_event_id": "$root1",
"thread_reply_to_event_id": "$reply1",
}
await channel.send(
OutboundMessage(
channel="matrix",
chat_id="!room:matrix.org",
content="Hi",
metadata=metadata,
)
)
content = client.room_send_calls[0]["content"]
assert content["m.relates_to"] == {
"rel_type": "m.thread",
"event_id": "$root1",
"m.in_reply_to": {"event_id": "$reply1"},
"is_falling_back": True,
}
@pytest.mark.asyncio
async def test_send_uses_encrypted_media_payload_in_encrypted_room(tmp_path) -> None:
channel = MatrixChannel(_make_config(e2ee_enabled=True), MessageBus())
@@ -851,6 +961,50 @@ async def test_send_does_not_parse_attachment_marker_without_media(tmp_path) ->
assert client.room_send_calls[0]["content"]["body"] == f"[attachment: {missing_path}]"
@pytest.mark.asyncio
async def test_send_passes_thread_relates_to_to_attachment_upload(monkeypatch) -> None:
channel = MatrixChannel(_make_config(), MessageBus())
client = _FakeAsyncClient("", "", "", None)
channel.client = client
channel._server_upload_limit_checked = True
channel._server_upload_limit_bytes = None
captured: dict[str, object] = {}
async def _fake_upload_and_send_attachment(
*,
room_id: str,
path: Path,
limit_bytes: int,
relates_to: dict[str, object] | None = None,
) -> str | None:
captured["relates_to"] = relates_to
return None
monkeypatch.setattr(channel, "_upload_and_send_attachment", _fake_upload_and_send_attachment)
metadata = {
"thread_root_event_id": "$root1",
"thread_reply_to_event_id": "$reply1",
}
await channel.send(
OutboundMessage(
channel="matrix",
chat_id="!room:matrix.org",
content="Hi",
media=["/tmp/fake.txt"],
metadata=metadata,
)
)
assert captured["relates_to"] == {
"rel_type": "m.thread",
"event_id": "$root1",
"m.in_reply_to": {"event_id": "$reply1"},
"is_falling_back": True,
}
@pytest.mark.asyncio
async def test_send_workspace_restriction_blocks_external_attachment(tmp_path) -> None:
workspace = tmp_path / "workspace"