diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 876d43d..0253415 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -102,8 +102,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. Before calling tools, briefly tell the user what you're about to do (one short sentence in the user's language). When remembering something important, write to {workspace_path}/memory/MEMORY.md diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index 3853725..c5efbf3 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,84 +8,89 @@ 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" + "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"}, "media": { "type": "array", "items": {"type": "string"}, - "description": "Optional: list of file paths to attach (images, audio, documents)" - } + "description": "Optional: list of file paths to attach (images, audio, documents)", + }, }, - "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, media: list[str] | None = None, - **kwargs: Any + **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" - - msg = OutboundMessage( - channel=channel, - chat_id=chat_id, - content=content, - media=media or [] - ) - + + 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, media=media or []) + try: await self._send_callback(msg) media_info = f" with {len(media)} attachments" if media else "" diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index eb921da..14d897b 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -374,6 +374,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" @@ -384,11 +385,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, @@ -396,6 +396,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: @@ -513,25 +531,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 {}: {}", @@ -552,8 +574,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 222f14e..c71bc52 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): @@ -775,6 +791,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 @@ -783,6 +801,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()) @@ -833,6 +881,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 0000000..f7bfad9 --- /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"