diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index d80785416..8aaecbec5 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -100,8 +100,11 @@ Your workspace is at: {workspace_path} - Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md IMPORTANT: When responding to direct questions or conversations, reply directly with your text response. -Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp). -For normal conversation, just respond with text - do not call the message tool. +Use the 'message' tool only when you need explicit channel delivery behavior: +- Send to a different channel/chat than the current session +- Send one or more file attachments via `media` (local file paths) +For normal conversation text, respond directly without calling the message tool. +Do not claim that attachments are impossible if a channel supports file send and you can provide local paths. Always be helpful, accurate, and concise. When using tools, explain what you're doing. When remembering something, write to {workspace_path}/memory/MEMORY.md""" diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index 347830fb0..b8e6e24fe 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -1,6 +1,6 @@ """Message tool for sending messages to users.""" -from typing import Any, Callable, Awaitable +from typing import Any, Awaitable, Callable from nanobot.agent.tools.base import Tool from nanobot.bus.events import OutboundMessage @@ -8,77 +8,88 @@ from nanobot.bus.events import OutboundMessage class MessageTool(Tool): """Tool to send messages to users on chat channels.""" - + def __init__( - self, + self, send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None, default_channel: str = "", - default_chat_id: str = "" + default_chat_id: str = "", ): self._send_callback = send_callback self._default_channel = default_channel self._default_chat_id = default_chat_id - + def set_context(self, channel: str, chat_id: str) -> None: """Set the current message context.""" self._default_channel = channel self._default_chat_id = chat_id - + def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None: """Set the callback for sending messages.""" self._send_callback = callback - + @property def name(self) -> str: return "message" - + @property def description(self) -> str: - return "Send a message to the user. Use this when you want to communicate something." - + return ( + "Send a message to the user. Supports optional media/attachment " + "paths for channels that can send files." + ) + @property def parameters(self) -> dict[str, Any]: return { "type": "object", "properties": { - "content": { - "type": "string", - "description": "The message content to send" - }, + "content": {"type": "string", "description": "The message content to send"}, "channel": { "type": "string", - "description": "Optional: target channel (telegram, discord, etc.)" + "description": "Optional: target channel (telegram, discord, etc.)", + }, + "chat_id": {"type": "string", "description": "Optional: target chat/user ID"}, + "media": { + "type": "array", + "description": "Optional: local file paths to send as attachments", + "items": {"type": "string"}, }, - "chat_id": { - "type": "string", - "description": "Optional: target chat/user ID" - } }, - "required": ["content"] + "required": ["content"], } - + async def execute( - self, - content: str, - channel: str | None = None, + self, + content: str, + channel: str | None = None, chat_id: str | None = None, - **kwargs: Any + media: list[str] | None = None, + **kwargs: Any, ) -> str: channel = channel or self._default_channel chat_id = chat_id or self._default_chat_id - + if not channel or not chat_id: return "Error: No target channel/chat specified" - + if not self._send_callback: return "Error: Message sending not configured" - + + media_paths: list[str] = [] + for item in media or []: + if isinstance(item, str): + candidate = item.strip() + if candidate: + media_paths.append(candidate) + msg = OutboundMessage( channel=channel, chat_id=chat_id, - content=content + content=content, + media=media_paths, ) - + try: await self._send_callback(msg) return f"Message sent to {channel}:{chat_id}" diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 5bdc4df09..b16a8788a 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -385,6 +385,7 @@ class MatrixChannel(BaseChannel): mime: str, size_bytes: int, mxc_url: str, + encryption_info: dict[str, Any] | None = None, ) -> dict[str, Any]: """Build Matrix content payload for an uploaded file/image/audio/video.""" msgtype = "m.file" @@ -395,11 +396,10 @@ class MatrixChannel(BaseChannel): elif mime.startswith("video/"): msgtype = "m.video" - return { + content: dict[str, Any] = { "msgtype": msgtype, "body": filename, "filename": filename, - "url": mxc_url, "info": { "mimetype": mime, "size": size_bytes, @@ -407,6 +407,24 @@ class MatrixChannel(BaseChannel): "m.mentions": {}, } + if encryption_info: + # Encrypted media events use `file` metadata (with url/hash/key/iv), + # while unencrypted media events use top-level `url`. + file_info = dict(encryption_info) + file_info["url"] = mxc_url + content["file"] = file_info + else: + content["url"] = mxc_url + + return content + + def _is_encrypted_room(self, room_id: str) -> bool: + """Return True if the Matrix room is known as encrypted.""" + if not self.client: + return False + room = getattr(self.client, "rooms", {}).get(room_id) + return bool(getattr(room, "encrypted", False)) + async def _send_room_content(self, room_id: str, content: dict[str, Any]) -> None: """Send Matrix m.room.message content with configured E2EE send options.""" if not self.client: @@ -520,25 +538,29 @@ class MatrixChannel(BaseChannel): ) return MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE.format(filename) + mime = mimetypes.guess_type(filename, strict=False)[0] or "application/octet-stream" + encrypt_upload = self.config.e2ee_enabled and self._is_encrypted_room(room_id) try: - data = resolved.read_bytes() - except OSError as e: + with resolved.open("rb") as data_provider: + upload_result = await self.client.upload( + data_provider, + content_type=mime, + filename=filename, + encrypt=encrypt_upload, + filesize=size_bytes, + ) + except Exception as e: logger.warning( - "Matrix outbound attachment read failed for {} ({}): {}", + "Matrix outbound attachment upload failed for {} ({}): {}", resolved, type(e).__name__, str(e), ) return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename) - - mime = mimetypes.guess_type(filename, strict=False)[0] or "application/octet-stream" - upload_result = await self.client.upload( - data, - content_type=mime, - filename=filename, - filesize=len(data), - ) upload_response = upload_result[0] if isinstance(upload_result, tuple) else upload_result + encryption_info: dict[str, Any] | None = None + if isinstance(upload_result, tuple) and isinstance(upload_result[1], dict): + encryption_info = upload_result[1] if isinstance(upload_response, UploadError): logger.warning( "Matrix outbound attachment upload failed for {}: {}", @@ -559,8 +581,9 @@ class MatrixChannel(BaseChannel): content = self._build_outbound_attachment_content( filename=filename, mime=mime, - size_bytes=len(data), + size_bytes=size_bytes, mxc_url=mxc_url, + encryption_info=encryption_info, ) try: await self._send_room_content(room_id, content) diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index 0c8734a15..1af9538c3 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -45,6 +45,7 @@ class _FakeAsyncClient: self.join_calls: list[str] = [] self.callbacks: list[tuple[object, object]] = [] self.response_callbacks: list[tuple[object, object]] = [] + self.rooms: dict[str, object] = {} self.room_send_calls: list[dict[str, object]] = [] self.typing_calls: list[tuple[str, bool, int]] = [] self.download_calls: list[dict[str, object]] = [] @@ -122,6 +123,11 @@ class _FakeAsyncClient: ): if self.raise_on_upload: raise RuntimeError("upload failed") + if isinstance(data_provider, (bytes, bytearray)): + raise TypeError( + f"data_provider type {type(data_provider)!r} is not of a usable type " + "(Callable, IOBase)" + ) self.upload_calls.append( { "data_provider": data_provider, @@ -133,6 +139,16 @@ class _FakeAsyncClient: ) if self.upload_response is not None: return self.upload_response + if encrypt: + return ( + SimpleNamespace(content_uri="mxc://example.org/uploaded"), + { + "v": "v2", + "iv": "iv", + "hashes": {"sha256": "hash"}, + "key": {"alg": "A256CTR", "k": "key"}, + }, + ) return SimpleNamespace(content_uri="mxc://example.org/uploaded"), None async def content_repository_config(self): @@ -758,6 +774,8 @@ async def test_send_uploads_media_and_sends_file_event(tmp_path) -> None: ) assert len(client.upload_calls) == 1 + assert not isinstance(client.upload_calls[0]["data_provider"], (bytes, bytearray)) + assert hasattr(client.upload_calls[0]["data_provider"], "read") assert client.upload_calls[0]["filename"] == "test.txt" assert client.upload_calls[0]["filesize"] == 5 assert len(client.room_send_calls) == 2 @@ -766,6 +784,36 @@ 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_uses_encrypted_media_payload_in_encrypted_room(tmp_path) -> None: + channel = MatrixChannel(_make_config(e2ee_enabled=True), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + client.rooms["!encrypted:matrix.org"] = SimpleNamespace(encrypted=True) + channel.client = client + + file_path = tmp_path / "secret.txt" + file_path.write_text("topsecret", encoding="utf-8") + + await channel.send( + OutboundMessage( + channel="matrix", + chat_id="!encrypted:matrix.org", + content="", + media=[str(file_path)], + ) + ) + + assert len(client.upload_calls) == 1 + assert client.upload_calls[0]["encrypt"] is True + assert len(client.room_send_calls) == 1 + content = client.room_send_calls[0]["content"] + assert content["msgtype"] == "m.file" + assert "file" in content + assert "url" not in content + assert content["file"]["url"] == "mxc://example.org/uploaded" + assert content["file"]["hashes"]["sha256"] == "hash" + + @pytest.mark.asyncio async def test_send_does_not_parse_attachment_marker_without_media(tmp_path) -> None: channel = MatrixChannel(_make_config(), MessageBus()) @@ -816,6 +864,33 @@ async def test_send_workspace_restriction_blocks_external_attachment(tmp_path) - assert client.room_send_calls[0]["content"]["body"] == "[attachment: external.txt - upload failed]" +@pytest.mark.asyncio +async def test_send_handles_upload_exception_and_reports_failure(tmp_path) -> None: + channel = MatrixChannel(_make_config(), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + client.raise_on_upload = True + channel.client = client + + file_path = tmp_path / "broken.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) == 0 + assert len(client.room_send_calls) == 1 + assert ( + client.room_send_calls[0]["content"]["body"] + == "Please review.\n[attachment: broken.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()) diff --git a/tests/test_message_tool.py b/tests/test_message_tool.py new file mode 100644 index 000000000..f7bfad91e --- /dev/null +++ b/tests/test_message_tool.py @@ -0,0 +1,37 @@ +import pytest + +from nanobot.agent.tools.message import MessageTool +from nanobot.bus.events import OutboundMessage + + +@pytest.mark.asyncio +async def test_message_tool_sends_media_paths_with_default_context() -> None: + sent: list[OutboundMessage] = [] + + async def _send(msg: OutboundMessage) -> None: + sent.append(msg) + + tool = MessageTool( + send_callback=_send, + default_channel="test-channel", + default_chat_id="!room:example.org", + ) + + result = await tool.execute( + content="Here is the file.", + media=[" /tmp/test.txt ", "", " ", "/tmp/report.pdf"], + ) + + assert result == "Message sent to test-channel:!room:example.org" + assert len(sent) == 1 + assert sent[0].channel == "test-channel" + assert sent[0].chat_id == "!room:example.org" + assert sent[0].content == "Here is the file." + assert sent[0].media == ["/tmp/test.txt", "/tmp/report.pdf"] + + +@pytest.mark.asyncio +async def test_message_tool_returns_error_when_no_target_context() -> None: + tool = MessageTool() + result = await tool.execute(content="test") + assert result == "Error: No target channel/chat specified"