Просмотр исходного кода

Bundle image batches into a single Chatto message

The base implementation posts each image on its own. A Chatto message
carries a list of attachment assets, so a batch belongs in one message —
one upload round, one notification, one gallery.

Entries that cannot be fetched are dropped with a warning and the count is
logged; if nothing survives we fall back to the base class so the user at
least gets the links. human_delay is ignored on purpose: there is a single
outbound call left to pace.
Paul Klumpp 1 неделя назад
Родитель
Сommit
bdf935e5c6
2 измененных файлов с 154 добавлено и 0 удалено
  1. 99 0
      adapter.py
  2. 55 0
      test_adapter.py

+ 99 - 0
adapter.py

@@ -1635,6 +1635,105 @@ class ChattoAdapter(BasePlatformAdapter):
             text = f"{caption}\n{image_url}"
             text = f"{caption}\n{image_url}"
         return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
         return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
 
 
+    async def _materialise_image(self, image_url: str) -> Tuple[Optional[str], bool]:
+        """Resolve one ``send_multiple_images`` entry to a local file path.
+
+        Accepts ``http(s)://`` URLs (downloaded to a temp file), ``file://``
+        URIs and bare paths.  Returns ``(path, is_temp)`` — the caller unlinks
+        when ``is_temp``.  ``(None, False)`` means the entry is unusable.
+        """
+        import tempfile
+        from urllib.parse import unquote as _unquote
+
+        if image_url.startswith(("http://", "https://")):
+            parsed = urlsplit(image_url)
+            ext = os.path.splitext(parsed.path)[1] or ".png"
+            tmp_fd, tmp_path = tempfile.mkstemp(suffix=ext, prefix="chatto_img_")
+            os.close(tmp_fd)
+            try:
+                data = await self._download_attachment_bytes(image_url)
+                with open(tmp_path, "wb") as f:
+                    f.write(data)
+            except Exception as e:
+                logger.warning("Chatto: image download failed for %s: %s", image_url, e)
+                try:
+                    os.unlink(tmp_path)
+                except OSError:
+                    pass
+                return None, False
+            return tmp_path, True
+
+        local = image_url
+        if local.startswith("file://"):
+            local = _unquote(urlsplit(local).path)
+        return self.validate_media_delivery_path(local), False
+
+    async def send_multiple_images(
+        self,
+        chat_id: str,
+        images: List[Tuple[str, str]],
+        metadata: Optional[Dict[str, Any]] = None,
+        human_delay: float = 0.0,
+    ) -> None:
+        """Send a batch of images as ONE message with several attachments.
+
+        The base implementation posts each image separately; a Chatto message
+        carries a list of attachment assets, so a batch belongs in a single
+        message (and a single notification).
+
+        ``human_delay`` is ignored deliberately — there is only one outbound
+        call to pace.  Entries that can't be fetched are dropped with a warning;
+        if nothing survives, we fall back to the base class so the user still
+        gets the links.
+
+        BasePlatformAdapter override
+        """
+        if len(images or []) < 2:
+            await super().send_multiple_images(
+                chat_id, images, metadata=metadata, human_delay=human_delay,
+            )
+            return
+
+        asset_ids: List[str] = []
+        captions: List[str] = []
+        for image_url, alt_text in images:
+            path, is_temp = await self._materialise_image(image_url)
+            if not path:
+                logger.warning("Chatto: skipping unusable image %s", image_url)
+                continue
+            try:
+                asset_id = await self._upload_asset(str(chat_id), path)
+            finally:
+                if is_temp:
+                    try:
+                        os.unlink(path)
+                    except OSError:
+                        pass
+            if not asset_id:
+                logger.warning("Chatto: upload failed for image %s", image_url)
+                continue
+            asset_ids.append(asset_id)
+            if alt_text:
+                captions.append(alt_text)
+
+        if not asset_ids:
+            logger.warning(
+                "Chatto: no image survived upload, falling back to per-image delivery",
+            )
+            await super().send_multiple_images(
+                chat_id, images, metadata=metadata, human_delay=human_delay,
+            )
+            return
+
+        if len(asset_ids) < len(images):
+            logger.warning(
+                "Chatto: sending %d of %d images — the rest could not be uploaded",
+                len(asset_ids), len(images),
+            )
+
+        await self._post_attachment_message(
+            chat_id, asset_ids, "\n".join(captions) or None, None, metadata,
+        )
 
 
 
 
 
 

+ 55 - 0
test_adapter.py

@@ -542,6 +542,61 @@ class TestNativeSends:
         assert "/tmp/clip.mp4" not in sent_text
         assert "/tmp/clip.mp4" not in sent_text
 
 
 
 
+# -- Batched image delivery --
+
+class TestSendMultipleImages:
+    """A batch of images belongs in ONE Chatto message."""
+
+    @pytest_asyncio.fixture
+    def adapter(self):
+        adapter = _make_adapter()
+        adapter._chatto_client.post_message = AsyncMock()
+        mock_msg = MagicMock()
+        mock_msg.id = "msg-1"
+        adapter._chatto_client.post_message.return_value = mock_msg
+        adapter._upload_asset = AsyncMock(side_effect=["asset-1", "asset-2"])
+        adapter.validate_media_delivery_path = MagicMock(side_effect=lambda p: p)
+        return adapter
+
+    async def test_bundles_into_single_message(self, adapter):
+        await adapter.send_multiple_images(
+            "room-1", [("/tmp/a.png", "first"), ("/tmp/b.png", "second")],
+        )
+        adapter._chatto_client.post_message.assert_called_once()
+        call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
+        assert call_kwargs["attachment_asset_ids"] == ["asset-1", "asset-2"]
+        assert call_kwargs["body"] == "first\nsecond"
+
+    async def test_single_image_uses_base_path(self, adapter):
+        """One image is not a batch — leave it to the base implementation.
+
+        Also pins the send_image_file signature: the base class calls it with
+        ``image_path=`` as a keyword, so a renamed parameter degrades every
+        native image send to a text notice.
+        """
+        adapter.send_image_file = AsyncMock(return_value=SendResult(success=True))
+        await adapter.send_multiple_images("room-1", [("file:///tmp/a.png", "only")])
+        adapter._upload_asset.assert_not_called()
+        adapter.send_image_file.assert_called_once()
+        assert adapter.send_image_file.call_args.kwargs["image_path"] == "/tmp/a.png"
+
+    async def test_partial_upload_failure_still_sends_the_rest(self, adapter):
+        adapter._upload_asset = AsyncMock(side_effect=["asset-1", None])
+        await adapter.send_multiple_images(
+            "room-1", [("/tmp/a.png", "first"), ("/tmp/b.png", "second")],
+        )
+        call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
+        assert call_kwargs["attachment_asset_ids"] == ["asset-1"]
+
+    async def test_file_uri_is_unquoted(self, adapter):
+        await adapter.send_multiple_images(
+            "room-1",
+            [("file:///tmp/a%20b.png", ""), ("/tmp/c.png", "")],
+        )
+        first_path = adapter._upload_asset.call_args_list[0].args[1]
+        assert first_path == "/tmp/a b.png"
+
+
 # -- Reaction event forwarding --
 # -- Reaction event forwarding --
 
 
 class TestReactionForwarding:
 class TestReactionForwarding: