diff --git a/plugins/platforms/buzz/adapter.py b/plugins/platforms/buzz/adapter.py index 8b77ae334120..93148290e9f1 100644 --- a/plugins/platforms/buzz/adapter.py +++ b/plugins/platforms/buzz/adapter.py @@ -675,33 +675,132 @@ async def send_image( """Send an image: local files upload via --file, URLs go as a link.""" local = Path(image_url).expanduser() if not image_url.startswith(("http://", "https://")) else None if local is not None and local.is_file(): - args = [ - "messages", "send", - "--channel", str(chat_id), - "--file", str(local), - "--content", "-", - ] - if reply_to: - args += ["--reply-to", str(reply_to)] - code, out, err = await self._run_cli(args, input_text=caption or "") - if code != 0: - return SendResult(success=False, error=_cli_error_message(err, code), retryable=code == 2) - try: - data = json.loads(out or "{}") - except ValueError: - data = {} - event_id = data.get("event_id") - if event_id: - self._mark_seen(str(chat_id), str(event_id)) - return SendResult( - success=bool(data.get("accepted", True)), - message_id=str(event_id) if event_id else None, - raw_response=data, + return await self._send_file_attachment( + chat_id, + local, + caption=caption, + reply_to=reply_to, + metadata=metadata, ) # Markdown renders in Buzz, so a URL arrives as a clickable image link. text = f"{caption}\n{image_url}" if caption else image_url return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata) + async def _send_file_attachment( + self, + chat_id: str, + file_path: Path, + *, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Upload a local file and publish it as a native Buzz attachment.""" + local = Path(file_path).expanduser() + if not local.is_file(): + return SendResult(success=False, error=f"File not found: {local}") + args = [ + "messages", "send", + "--channel", str(chat_id), + "--file", str(local), + "--content", "-", + ] + reply_target = reply_to or (metadata or {}).get("thread_id") + if reply_target: + args += ["--reply-to", str(reply_target)] + code, out, err = await self._run_cli(args, input_text=caption or "") + if code != 0: + return SendResult( + success=False, + error=_cli_error_message(err, code), + retryable=code == 2, + ) + try: + data = json.loads(out or "{}") + except ValueError: + data = {} + event_id = data.get("event_id") + if event_id: + self._mark_seen(str(chat_id), str(event_id)) + return SendResult( + success=bool(data.get("accepted", True)), + message_id=str(event_id) if event_id else None, + raw_response=data, + ) + + async def send_image_file( + self, + chat_id: str, + image_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> SendResult: + """Upload a local image through Buzz's native ``--file`` path.""" + return await self._send_file_attachment( + chat_id, + Path(image_path), + caption=caption, + reply_to=reply_to, + metadata=metadata, + ) + + async def send_document( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + file_name: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> SendResult: + """Upload a local document through Buzz's native ``--file`` path.""" + return await self._send_file_attachment( + chat_id, + Path(file_path), + caption=caption, + reply_to=reply_to, + metadata=metadata, + ) + + async def send_video( + self, + chat_id: str, + video_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> SendResult: + """Upload a local video through Buzz's native ``--file`` path.""" + return await self._send_file_attachment( + chat_id, + Path(video_path), + caption=caption, + reply_to=reply_to, + metadata=metadata, + ) + + async def send_voice( + self, + chat_id: str, + audio_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> SendResult: + """Upload a local audio file through Buzz's native ``--file`` path.""" + return await self._send_file_attachment( + chat_id, + Path(audio_path), + caption=caption, + reply_to=reply_to, + metadata=metadata, + ) + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: chat_id = str(chat_id) state = self._channel_state.get(chat_id) diff --git a/tests/gateway/test_buzz_adapter.py b/tests/gateway/test_buzz_adapter.py index d612021bfe39..bd02cb91581b 100644 --- a/tests/gateway/test_buzz_adapter.py +++ b/tests/gateway/test_buzz_adapter.py @@ -421,6 +421,44 @@ async def test_send_image_local_file_uses_file_flag(self, tmp_path): args, _stdin = cli.calls[0] assert args[args.index("--file") + 1] == str(img) + @pytest.mark.asyncio + async def test_send_image_file_uses_native_file_flag_and_thread_metadata(self, tmp_path): + img = tmp_path / "preview.jpg" + img.write_bytes(b"\xff\xd8\xff fake") + adapter = _make_adapter() + cli = _ScriptedCli() + cli.script("messages", "send", {"accepted": True, "event_id": "evt127", "message": ""}) + adapter._run_cli = cli + + result = await adapter.send_image_file( + CHANNEL, + str(img), + caption="preview", + metadata={"thread_id": "root-event"}, + ) + + assert result.success is True + args, stdin_text = cli.calls[0] + assert args[args.index("--file") + 1] == str(img) + assert args[args.index("--reply-to") + 1] == "root-event" + assert stdin_text == "preview" + + @pytest.mark.asyncio + async def test_send_document_uses_native_file_flag(self, tmp_path): + document = tmp_path / "package.zip" + document.write_bytes(b"PK fake") + adapter = _make_adapter() + cli = _ScriptedCli() + cli.script("messages", "send", {"accepted": True, "event_id": "evt128", "message": ""}) + adapter._run_cli = cli + + result = await adapter.send_document(CHANNEL, str(document), caption="files") + + assert result.success is True + args, stdin_text = cli.calls[0] + assert args[args.index("--file") + 1] == str(document) + assert stdin_text == "files" + # ── Lifecycle ─────────────────────────────────────────────────────────────