feat(matrix): add outbound media uploads and unify media limits with maxMediaBytes
- Use OutboundMessage.media for Matrix file/image/audio/video sends - Apply effective media limit as min(m.upload.size, maxMediaBytes) - Rename matrix config key maxInboundMediaBytes -> maxMediaBytes (no legacy fallback)
This commit is contained in:
@@ -48,12 +48,16 @@ class _FakeAsyncClient:
|
||||
self.room_send_calls: list[dict[str, object]] = []
|
||||
self.typing_calls: list[tuple[str, bool, int]] = []
|
||||
self.download_calls: list[dict[str, object]] = []
|
||||
self.upload_calls: list[dict[str, object]] = []
|
||||
self.download_response: object | None = None
|
||||
self.download_bytes: bytes = b"media"
|
||||
self.download_content_type: str = "application/octet-stream"
|
||||
self.download_filename: str | None = None
|
||||
self.upload_response: object | None = None
|
||||
self.content_repository_config_response: object = SimpleNamespace(upload_size=None)
|
||||
self.raise_on_send = False
|
||||
self.raise_on_typing = False
|
||||
self.raise_on_upload = False
|
||||
|
||||
def add_event_callback(self, callback, event_type) -> None:
|
||||
self.callbacks.append((callback, event_type))
|
||||
@@ -108,6 +112,32 @@ class _FakeAsyncClient:
|
||||
filename=self.download_filename,
|
||||
)
|
||||
|
||||
async def upload(
|
||||
self,
|
||||
data_provider,
|
||||
content_type: str | None = None,
|
||||
filename: str | None = None,
|
||||
filesize: int | None = None,
|
||||
encrypt: bool = False,
|
||||
):
|
||||
if self.raise_on_upload:
|
||||
raise RuntimeError("upload failed")
|
||||
self.upload_calls.append(
|
||||
{
|
||||
"data_provider": data_provider,
|
||||
"content_type": content_type,
|
||||
"filename": filename,
|
||||
"filesize": filesize,
|
||||
"encrypt": encrypt,
|
||||
}
|
||||
)
|
||||
if self.upload_response is not None:
|
||||
return self.upload_response
|
||||
return SimpleNamespace(content_uri="mxc://example.org/uploaded")
|
||||
|
||||
async def content_repository_config(self):
|
||||
return self.content_repository_config_response
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
@@ -523,7 +553,7 @@ async def test_on_media_message_respects_declared_size_limit(
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path)
|
||||
|
||||
channel = MatrixChannel(_make_config(max_inbound_media_bytes=3), MessageBus())
|
||||
channel = MatrixChannel(_make_config(max_media_bytes=3), MessageBus())
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
channel.client = client
|
||||
|
||||
@@ -552,6 +582,42 @@ async def test_on_media_message_respects_declared_size_limit(
|
||||
assert "[attachment: large.bin - too large]" in handled[0]["content"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_media_message_uses_server_limit_when_smaller_than_local_limit(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path)
|
||||
|
||||
channel = MatrixChannel(_make_config(max_media_bytes=10), MessageBus())
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
client.content_repository_config_response = SimpleNamespace(upload_size=3)
|
||||
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="large.bin",
|
||||
url="mxc://example.org/large",
|
||||
event_id="$event2_server",
|
||||
source={"content": {"msgtype": "m.file", "info": {"size": 5}}},
|
||||
)
|
||||
|
||||
await channel._on_media_message(room, event)
|
||||
|
||||
assert client.download_calls == []
|
||||
assert len(handled) == 1
|
||||
assert handled[0]["media"] == []
|
||||
assert handled[0]["metadata"]["attachments"] == []
|
||||
assert "[attachment: large.bin - too large]" in handled[0]["content"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_media_message_handles_download_error(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path)
|
||||
@@ -690,6 +756,107 @@ async def test_send_clears_typing_after_send() -> None:
|
||||
assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_uploads_media_and_sends_file_event(tmp_path) -> None:
|
||||
channel = MatrixChannel(_make_config(), MessageBus())
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
channel.client = client
|
||||
|
||||
file_path = tmp_path / "test.txt"
|
||||
file_path.write_text("hello", encoding="utf-8")
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="matrix",
|
||||
chat_id="!room:matrix.org",
|
||||
content="Please review.",
|
||||
media=[str(file_path)],
|
||||
)
|
||||
)
|
||||
|
||||
assert len(client.upload_calls) == 1
|
||||
assert client.upload_calls[0]["filename"] == "test.txt"
|
||||
assert client.upload_calls[0]["filesize"] == 5
|
||||
assert len(client.room_send_calls) == 2
|
||||
assert client.room_send_calls[0]["content"]["msgtype"] == "m.file"
|
||||
assert client.room_send_calls[0]["content"]["url"] == "mxc://example.org/uploaded"
|
||||
assert client.room_send_calls[1]["content"]["body"] == "Please review."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_does_not_parse_attachment_marker_without_media(tmp_path) -> None:
|
||||
channel = MatrixChannel(_make_config(), MessageBus())
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
channel.client = client
|
||||
|
||||
missing_path = tmp_path / "missing.txt"
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="matrix",
|
||||
chat_id="!room:matrix.org",
|
||||
content=f"[attachment: {missing_path}]",
|
||||
)
|
||||
)
|
||||
|
||||
assert client.upload_calls == []
|
||||
assert len(client.room_send_calls) == 1
|
||||
assert client.room_send_calls[0]["content"]["body"] == f"[attachment: {missing_path}]"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_workspace_restriction_blocks_external_attachment(tmp_path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
file_path = tmp_path / "external.txt"
|
||||
file_path.write_text("outside", encoding="utf-8")
|
||||
|
||||
channel = MatrixChannel(
|
||||
_make_config(),
|
||||
MessageBus(),
|
||||
restrict_to_workspace=True,
|
||||
workspace=workspace,
|
||||
)
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
channel.client = client
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="matrix",
|
||||
chat_id="!room:matrix.org",
|
||||
content="",
|
||||
media=[str(file_path)],
|
||||
)
|
||||
)
|
||||
|
||||
assert client.upload_calls == []
|
||||
assert len(client.room_send_calls) == 1
|
||||
assert client.room_send_calls[0]["content"]["body"] == "[attachment: external.txt - upload failed]"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_uses_server_upload_limit_when_smaller_than_local_limit(tmp_path) -> None:
|
||||
channel = MatrixChannel(_make_config(max_media_bytes=10), MessageBus())
|
||||
client = _FakeAsyncClient("", "", "", None)
|
||||
client.content_repository_config_response = SimpleNamespace(upload_size=3)
|
||||
channel.client = client
|
||||
|
||||
file_path = tmp_path / "tiny.txt"
|
||||
file_path.write_text("hello", encoding="utf-8")
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="matrix",
|
||||
chat_id="!room:matrix.org",
|
||||
content="",
|
||||
media=[str(file_path)],
|
||||
)
|
||||
)
|
||||
|
||||
assert client.upload_calls == []
|
||||
assert len(client.room_send_calls) == 1
|
||||
assert client.room_send_calls[0]["content"]["body"] == "[attachment: tiny.txt - too large]"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_omits_ignore_unverified_devices_when_e2ee_disabled() -> None:
|
||||
channel = MatrixChannel(_make_config(e2ee_enabled=False), MessageBus())
|
||||
|
||||
Reference in New Issue
Block a user