diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index fb691ec535..102e055ffc 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -18,7 +18,7 @@ import tempfile import threading import time from collections import defaultdict -from typing import Callable, Dict, Optional, Any +from typing import Callable, Dict, List, Optional, Any, Tuple logger = logging.getLogger(__name__) @@ -1343,6 +1343,134 @@ class DiscordAdapter(BasePlatformAdapter): msg = await channel.send(content=caption if caption else None, file=file) return SendResult(success=True, message_id=str(msg.id)) + 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 a single Discord message with multiple attachments. + + Discord permits up to 10 file attachments per message. Batches are + chunked accordingly. URL images are downloaded into memory and + uploaded as inline attachments (same pattern as ``send_image`` so + they render inline, not as bare links). Local files are opened + directly. On per-chunk failure the remaining images in that chunk + fall back to the base per-image loop. + """ + if not self._client: + return + if not images: + return + + try: + import discord as _discord_mod + import io as _io + from urllib.parse import unquote as _unquote + except Exception: # pragma: no cover + await super().send_multiple_images(chat_id, images, metadata, human_delay) + return + + try: + channel = self._client.get_channel(int(chat_id)) + if not channel: + channel = await self._client.fetch_channel(int(chat_id)) + if not channel: + logger.warning("[%s] Channel %s not found for multi-image send", self.name, chat_id) + return + except Exception as e: + logger.warning("[%s] Failed to resolve channel for multi-image send: %s", self.name, e) + await super().send_multiple_images(chat_id, images, metadata, human_delay) + return + + CHUNK = 10 + chunks = [images[i:i + CHUNK] for i in range(0, len(images), CHUNK)] + + for chunk_idx, chunk in enumerate(chunks): + if human_delay > 0 and chunk_idx > 0: + await asyncio.sleep(human_delay) + + files: List[Any] = [] + captions: List[str] = [] + aiohttp_session = None + try: + for image_url, alt_text in chunk: + if alt_text: + captions.append(alt_text) + if image_url.startswith("file://"): + local_path = _unquote(image_url[7:]) + if not os.path.exists(local_path): + logger.warning("[%s] Skipping missing image: %s", self.name, local_path) + continue + files.append(_discord_mod.File(local_path, filename=os.path.basename(local_path))) + else: + if not is_safe_url(image_url): + logger.warning("[%s] Blocked unsafe image URL in batch", self.name) + continue + # Download to BytesIO so it renders inline + try: + import aiohttp as _aiohttp + from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp + _proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY") + _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy) + if aiohttp_session is None: + aiohttp_session = _aiohttp.ClientSession(**_sess_kw) + async with aiohttp_session.get( + image_url, timeout=_aiohttp.ClientTimeout(total=30), **_req_kw, + ) as resp: + if resp.status != 200: + logger.warning( + "[%s] Failed to download image (HTTP %d) in batch: %s", + self.name, resp.status, image_url[:80], + ) + continue + data = await resp.read() + ct = resp.headers.get("content-type", "image/png") + ext = "png" + if "jpeg" in ct or "jpg" in ct: + ext = "jpg" + elif "gif" in ct: + ext = "gif" + elif "webp" in ct: + ext = "webp" + files.append(_discord_mod.File(_io.BytesIO(data), filename=f"image_{len(files)}.{ext}")) + except Exception as dl_err: + logger.warning("[%s] Download failed for %s: %s", self.name, image_url[:80], dl_err) + continue + + if not files: + continue + + # Use the first caption if any (Discord only has one message body for the group) + content = captions[0] if captions else None + logger.info( + "[%s] Sending %d image(s) as single Discord message (chunk %d/%d)", + self.name, len(files), chunk_idx + 1, len(chunks), + ) + + if self._is_forum_parent(channel): + await self._forum_post_file( + channel, + content=(content or "").strip(), + files=files, + ) + else: + await channel.send(content=content, files=files) + except Exception as e: + logger.warning( + "[%s] Multi-image Discord send failed (chunk %d/%d), falling back to per-image: %s", + self.name, chunk_idx + 1, len(chunks), e, + exc_info=True, + ) + await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay) + finally: + if aiohttp_session is not None: + try: + await aiohttp_session.close() + except Exception: + pass + async def play_tts( self, chat_id: str, diff --git a/gateway/platforms/email.py b/gateway/platforms/email.py index 9d12441357..a343692636 100644 --- a/gateway/platforms/email.py +++ b/gateway/platforms/email.py @@ -31,7 +31,7 @@ from email.mime.base import MIMEBase from email.utils import formatdate from email import encoders from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from gateway.platforms.base import ( BasePlatformAdapter, @@ -540,6 +540,113 @@ class EmailAdapter(BasePlatformAdapter): text += f"\n\nImage: {image_url}" return await self.send(chat_id, text.strip(), reply_to) + 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 a single email with multiple MIME attachments. + + Local files are attached directly. URL images have their URL + appended to the body (email adapter does not download remote + images). No hard cap — email clients handle dozens of + attachments fine, subject to SMTP message size limits. + """ + if not images: + return + + from urllib.parse import unquote as _unquote + + body_parts: List[str] = [] + local_paths: List[str] = [] + for image_url, alt_text in images: + if alt_text: + body_parts.append(alt_text) + if image_url.startswith("file://"): + local_path = _unquote(image_url[7:]) + if Path(local_path).exists(): + local_paths.append(local_path) + else: + logger.warning("[Email] Skipping missing image: %s", local_path) + else: + # Remote URLs just get linked in the body (parity with send_image) + body_parts.append(f"Image: {image_url}") + + if not local_paths and not body_parts: + return + + body = "\n\n".join(body_parts) + + try: + loop = asyncio.get_running_loop() + await loop.run_in_executor( + None, + self._send_email_with_attachments, + chat_id, + body, + local_paths, + ) + except Exception as e: + logger.error("[Email] Multi-image send failed, falling back: %s", e, exc_info=True) + await super().send_multiple_images(chat_id, images, metadata, human_delay) + + def _send_email_with_attachments( + self, + to_addr: str, + body: str, + file_paths: List[str], + ) -> str: + """Send an email with multiple file attachments via SMTP.""" + msg = MIMEMultipart() + msg["From"] = self._address + msg["To"] = to_addr + + ctx = self._thread_context.get(to_addr, {}) + subject = ctx.get("subject", "Hermes Agent") + if not subject.startswith("Re:"): + subject = f"Re: {subject}" + msg["Subject"] = subject + + original_msg_id = ctx.get("message_id") + if original_msg_id: + msg["In-Reply-To"] = original_msg_id + msg["References"] = original_msg_id + + msg["Date"] = formatdate(localtime=True) + msg_id = f"" + msg["Message-ID"] = msg_id + + if body: + msg.attach(MIMEText(body, "plain", "utf-8")) + + for file_path in file_paths: + p = Path(file_path) + try: + with open(p, "rb") as f: + part = MIMEBase("application", "octet-stream") + part.set_payload(f.read()) + encoders.encode_base64(part) + part.add_header("Content-Disposition", f"attachment; filename={p.name}") + msg.attach(part) + except Exception as e: + logger.warning("[Email] Failed to attach %s: %s", file_path, e) + + smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30) + try: + smtp.starttls(context=ssl.create_default_context()) + smtp.login(self._address, self._password) + smtp.send_message(msg) + finally: + try: + smtp.quit() + except Exception: + smtp.close() + + logger.info("[Email] Sent multi-attachment email to %s (%d files)", to_addr, len(file_paths)) + return msg_id + async def send_document( self, chat_id: str, diff --git a/gateway/platforms/mattermost.py b/gateway/platforms/mattermost.py index 0fc72ca013..ef3c134a03 100644 --- a/gateway/platforms/mattermost.py +++ b/gateway/platforms/mattermost.py @@ -19,7 +19,7 @@ import logging import os import re from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import MessageDeduplicator @@ -496,6 +496,100 @@ class MattermostAdapter(BasePlatformAdapter): return SendResult(success=False, error="Failed to post with file") return SendResult(success=True, message_id=data["id"]) + 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 a single Mattermost post with multiple attachments. + + Mattermost supports up to 5 ``file_ids`` per post. Each image is + uploaded individually (Mattermost's file API is one-at-a-time), + then a single post is created referencing all uploaded file_ids + at once. Batches larger than 5 are chunked. Falls back to the + base per-image loop on total failure. + """ + if not images: + return + + import mimetypes + import aiohttp + from urllib.parse import unquote as _unquote + + CHUNK = 5 # Mattermost post file_ids cap + chunks = [images[i:i + CHUNK] for i in range(0, len(images), CHUNK)] + + for chunk_idx, chunk in enumerate(chunks): + if human_delay > 0 and chunk_idx > 0: + await asyncio.sleep(human_delay) + + file_ids: List[str] = [] + caption_parts: List[str] = [] + try: + for image_url, alt_text in chunk: + if alt_text: + caption_parts.append(alt_text) + + if image_url.startswith("file://"): + local_path = _unquote(image_url[7:]) + p = Path(local_path) + if not p.exists(): + logger.warning("Mattermost: skipping missing image %s", local_path) + continue + fname = p.name + ct = mimetypes.guess_type(fname)[0] or "image/png" + file_data = p.read_bytes() + else: + from tools.url_safety import is_safe_url + if not is_safe_url(image_url): + logger.warning("Mattermost: blocked unsafe image URL in batch") + continue + try: + async with self._session.get( + image_url, timeout=aiohttp.ClientTimeout(total=30) + ) as resp: + if resp.status >= 400: + logger.warning( + "Mattermost: failed to download image (HTTP %d): %s", + resp.status, image_url[:80], + ) + continue + file_data = await resp.read() + ct = resp.content_type or "image/png" + except Exception as dl_err: + logger.warning("Mattermost: download failed for %s: %s", image_url[:80], dl_err) + continue + fname = image_url.rsplit("/", 1)[-1].split("?")[0] or f"image_{len(file_ids)}.png" + + fid = await self._upload_file(chat_id, file_data, fname, ct) + if fid: + file_ids.append(fid) + + if not file_ids: + continue + + payload: Dict[str, Any] = { + "channel_id": chat_id, + "message": "\n".join(caption_parts), + "file_ids": file_ids, + } + logger.info( + "Mattermost: sending %d image(s) as single post (chunk %d/%d)", + len(file_ids), chunk_idx + 1, len(chunks), + ) + data = await self._api_post("posts", payload) + if not data or "id" not in data: + logger.warning("Mattermost: multi-image post failed, falling back") + await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay) + except Exception as e: + logger.warning( + "Mattermost: multi-image send failed (chunk %d/%d), falling back: %s", + chunk_idx + 1, len(chunks), e, exc_info=True, + ) + await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay) + # ------------------------------------------------------------------ # WebSocket # ------------------------------------------------------------------ diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index e18594f564..77341c9ce0 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -792,6 +792,111 @@ class SlackAdapter(BasePlatformAdapter): raise last_exc + 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 a single Slack message with multiple file uploads. + + Uses ``files_upload_v2`` with its ``file_uploads`` parameter so all + images show up attached to one ``initial_comment`` message instead + of N separate messages. Falls back to the base per-image loop on + any failure. + + The batch limit is 10 file uploads per call (Slack server-side cap). + """ + if not self._app: + return + if not images: + return + + try: + import httpx as _httpx + from urllib.parse import unquote as _unquote + from tools.url_safety import is_safe_url as _is_safe_url + except Exception: + await super().send_multiple_images(chat_id, images, metadata, human_delay) + return + + thread_ts = self._resolve_thread_ts(None, metadata) + + CHUNK = 10 + chunks = [images[i:i + CHUNK] for i in range(0, len(images), CHUNK)] + + for chunk_idx, chunk in enumerate(chunks): + if human_delay > 0 and chunk_idx > 0: + await asyncio.sleep(human_delay) + + file_uploads: List[Dict[str, Any]] = [] + initial_comment_parts: List[str] = [] + try: + async with _httpx.AsyncClient(timeout=30.0, follow_redirects=True) as http_client: + for image_url, alt_text in chunk: + if alt_text: + initial_comment_parts.append(alt_text) + + if image_url.startswith("file://"): + local_path = _unquote(image_url[7:]) + if not os.path.exists(local_path): + logger.warning("[Slack] Skipping missing image: %s", local_path) + continue + file_uploads.append({ + "file": local_path, + "filename": os.path.basename(local_path), + }) + else: + if not _is_safe_url(image_url): + logger.warning("[Slack] Blocked unsafe image URL in batch") + continue + try: + response = await http_client.get(image_url) + response.raise_for_status() + ext = "png" + ct = response.headers.get("content-type", "") + if "jpeg" in ct or "jpg" in ct: + ext = "jpg" + elif "gif" in ct: + ext = "gif" + elif "webp" in ct: + ext = "webp" + file_uploads.append({ + "content": response.content, + "filename": f"image_{len(file_uploads)}.{ext}", + }) + except Exception as dl_err: + logger.warning( + "[Slack] Download failed for %s: %s", + safe_url_for_log(image_url), dl_err, + ) + continue + + if not file_uploads: + continue + + initial_comment = "\n".join(initial_comment_parts) if initial_comment_parts else "" + logger.info( + "[Slack] Sending %d image(s) in single files_upload_v2 (chunk %d/%d)", + len(file_uploads), chunk_idx + 1, len(chunks), + ) + result = await self._get_client(chat_id).files_upload_v2( + channel=chat_id, + file_uploads=file_uploads, + initial_comment=initial_comment, + thread_ts=thread_ts, + ) + self._record_uploaded_file_thread(chat_id, thread_ts) + _ = result + except Exception as e: + logger.warning( + "[Slack] Multi-image files_upload_v2 failed (chunk %d/%d), falling back to per-image: %s", + chunk_idx + 1, len(chunks), e, + exc_info=True, + ) + await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay) + def _record_uploaded_file_thread(self, chat_id: str, thread_ts: Optional[str]) -> None: """Treat successful file uploads as bot participation in a thread.""" if not thread_ts: diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index b58ca45ec9..23fa8c6962 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -1992,6 +1992,117 @@ class TelegramAdapter(BasePlatformAdapter): ) return await super().send_voice(chat_id, audio_path, caption, reply_to) + async def send_multiple_images( + self, + chat_id: str, + images: List[tuple], + metadata: Optional[Dict[str, Any]] = None, + human_delay: float = 0.0, + ) -> None: + """Send a batch of images natively via Telegram's media group API. + + Telegram's ``send_media_group`` bundles up to 10 photos/videos into + a single album. Larger batches are chunked. Animated GIFs cannot + go into a media group (they require ``send_animation``), so they + are peeled off and sent individually via the base default path. + + URL-based photos go into the group directly; local files are + opened as byte streams. On failure the whole batch falls back to + the base adapter's per-image loop. + """ + if not self._bot: + return + if not images: + return + + try: + from telegram import InputMediaPhoto + except Exception as exc: # pragma: no cover - missing SDK + logger.warning( + "[%s] InputMediaPhoto unavailable, falling back to per-image send: %s", + self.name, exc, + ) + await super().send_multiple_images(chat_id, images, metadata, human_delay) + return + + # Peel off animations — they need send_animation, not send_media_group + animations: List[tuple] = [] + photos: List[tuple] = [] + for image_url, alt_text in images: + if not image_url.startswith("file://") and self._is_animation_url(image_url): + animations.append((image_url, alt_text)) + else: + photos.append((image_url, alt_text)) + + # Animations: route through the base default (per-image send_animation) + if animations: + await super().send_multiple_images( + chat_id, animations, metadata, human_delay=human_delay, + ) + + if not photos: + return + + from urllib.parse import unquote as _unquote + _thread = self._metadata_thread_id(metadata) + _thread_id = self._message_thread_id_for_send(_thread) + + # Chunk into groups of 10 (Telegram's album limit) + CHUNK = 10 + chunks = [photos[i:i + CHUNK] for i in range(0, len(photos), CHUNK)] + + for chunk_idx, chunk in enumerate(chunks): + if human_delay > 0 and chunk_idx > 0: + await asyncio.sleep(human_delay) + + media: List[Any] = [] + opened_files: List[Any] = [] + try: + for image_url, alt_text in chunk: + caption = alt_text[:1024] if alt_text else None + if image_url.startswith("file://"): + local_path = _unquote(image_url[7:]) + if not os.path.exists(local_path): + logger.warning( + "[%s] Skipping missing image in media group: %s", + self.name, local_path, + ) + continue + fh = open(local_path, "rb") + opened_files.append(fh) + media.append(InputMediaPhoto(media=fh, caption=caption)) + else: + media.append(InputMediaPhoto(media=image_url, caption=caption)) + + if not media: + continue + + logger.info( + "[%s] Sending media group of %d photo(s) (chunk %d/%d)", + self.name, len(media), chunk_idx + 1, len(chunks), + ) + await self._bot.send_media_group( + chat_id=int(chat_id), + media=media, + message_thread_id=_thread_id, + ) + except Exception as e: + logger.warning( + "[%s] send_media_group failed (chunk %d/%d), falling back to per-image: %s", + self.name, chunk_idx + 1, len(chunks), e, + exc_info=True, + ) + # Fallback: send each photo in this chunk individually + await super().send_multiple_images( + chat_id, chunk, metadata, human_delay=human_delay, + ) + finally: + for fh in opened_files: + try: + fh.close() + except Exception: + pass + async def send_image_file( self, chat_id: str, diff --git a/scripts/release.py b/scripts/release.py index 67a24919d4..f94fbc379c 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -58,6 +58,7 @@ AUTHOR_MAP = { "nbot@liizfq.top": "liizfq", "274096618+hermes-agent-dhabibi@users.noreply.github.com": "dhabibi", "dejie.guo@gmail.com": "JayGwod", + "maxence@groine.fr": "MaxyMoos", # OpenViking viking_read salvage (April 2026) "hitesh@gmail.com": "htsh", "pty819@outlook.com": "pty819", diff --git a/tests/gateway/test_send_multiple_images.py b/tests/gateway/test_send_multiple_images.py new file mode 100644 index 0000000000..06983a4b6b --- /dev/null +++ b/tests/gateway/test_send_multiple_images.py @@ -0,0 +1,463 @@ +""" +Tests for ``send_multiple_images`` native batching across platforms. + +Covers: + - Base default loop (per-image fallback for platforms without native batching) + - Telegram: ``bot.send_media_group`` with chunking at 10 + - Discord: ``channel.send(files=[...])`` with chunking at 10 + - Slack: ``files_upload_v2(file_uploads=[...])`` with chunking at 10 + - Mattermost: single post with ``file_ids`` list (chunk at 5) + - Email: single email with multiple MIME attachments + +Signal's native implementation is covered by test_signal.py. +""" + +import asyncio +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from gateway.config import PlatformConfig +from gateway.platforms.base import BasePlatformAdapter + + +def _run(coro): + return asyncio.run(coro) + + +# --------------------------------------------------------------------------- +# Base default loop +# --------------------------------------------------------------------------- + + +class _StubAdapter(BasePlatformAdapter): + """Minimal adapter that records per-image send calls.""" + + name = "stub" + + def __init__(self): + self.sent_images = [] + self.sent_animations = [] + self.sent_files = [] + + async def connect(self): + return True + + async def disconnect(self): + return None + + async def send(self, chat_id, content, reply_to=None, **kwargs): + from gateway.platforms.base import SendResult + return SendResult(success=True) + + async def get_chat_info(self, chat_id): + return {} + + async def send_image(self, chat_id, image_url, caption=None, **kwargs): + from gateway.platforms.base import SendResult + self.sent_images.append((chat_id, image_url, caption)) + return SendResult(success=True, message_id=str(len(self.sent_images))) + + async def send_animation(self, chat_id, animation_url, caption=None, **kwargs): + from gateway.platforms.base import SendResult + self.sent_animations.append((chat_id, animation_url, caption)) + return SendResult(success=True, message_id=str(len(self.sent_animations))) + + async def send_image_file(self, chat_id, image_path, caption=None, **kwargs): + from gateway.platforms.base import SendResult + self.sent_files.append((chat_id, image_path, caption)) + return SendResult(success=True, message_id=str(len(self.sent_files))) + + +class TestBaseDefaultLoop: + def test_loops_per_image_by_default(self): + a = _StubAdapter() + images = [ + ("https://x.com/a.png", "alt 1"), + ("https://x.com/b.png", "alt 2"), + ("file:///tmp/foo.png", "local"), + ("https://x.com/c.gif", ""), + ] + _run(a.send_multiple_images("chat1", images)) + # 2 URL images + 1 animation + 1 local file + assert len(a.sent_images) == 2 + assert len(a.sent_animations) == 1 + assert len(a.sent_files) == 1 + assert a.sent_files[0][1] == "/tmp/foo.png" + + def test_empty_batch_is_noop(self): + a = _StubAdapter() + _run(a.send_multiple_images("chat1", [])) + assert a.sent_images == [] + assert a.sent_animations == [] + assert a.sent_files == [] + + +# --------------------------------------------------------------------------- +# Telegram mocks setup (shared with test_send_image_file pattern) +# --------------------------------------------------------------------------- + + +def _ensure_telegram_mock(): + if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"): + return + telegram_mod = MagicMock() + telegram_mod.ext.ContextTypes.DEFAULT_TYPE = type(None) + telegram_mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2" + telegram_mod.constants.ChatType.GROUP = "group" + telegram_mod.constants.ChatType.SUPERGROUP = "supergroup" + telegram_mod.constants.ChatType.CHANNEL = "channel" + telegram_mod.constants.ChatType.PRIVATE = "private" + for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"): + sys.modules.setdefault(name, telegram_mod) + + +_ensure_telegram_mock() + +from gateway.platforms.telegram import TelegramAdapter # noqa: E402 + + +class TestTelegramMultiImage: + @pytest.fixture + def adapter(self): + config = PlatformConfig(enabled=True, token="fake-token") + a = TelegramAdapter(config) + a._bot = MagicMock() + a._bot.send_media_group = AsyncMock(return_value=[MagicMock(message_id=1)]) + return a + + def test_single_batch_under_10_calls_send_media_group_once(self, adapter): + """3 photos → one send_media_group call with 3 items.""" + import telegram + images = [(f"https://x.com/{i}.png", f"alt{i}") for i in range(3)] + # Make InputMediaPhoto a concrete class that records its args + telegram.InputMediaPhoto = MagicMock(side_effect=lambda media, caption=None: {"media": media, "caption": caption}) + + _run(adapter.send_multiple_images("12345", images)) + + adapter._bot.send_media_group.assert_awaited_once() + call_kwargs = adapter._bot.send_media_group.call_args.kwargs + assert call_kwargs["chat_id"] == 12345 + assert len(call_kwargs["media"]) == 3 + + def test_batch_over_10_chunks(self, adapter): + """15 photos → two send_media_group calls (10 + 5).""" + import telegram + images = [(f"https://x.com/{i}.png", "") for i in range(15)] + telegram.InputMediaPhoto = MagicMock(side_effect=lambda media, caption=None: {"media": media}) + + _run(adapter.send_multiple_images("12345", images)) + + assert adapter._bot.send_media_group.await_count == 2 + sizes = [len(c.kwargs["media"]) for c in adapter._bot.send_media_group.await_args_list] + assert sizes == [10, 5] + + def test_animations_routed_to_send_animation(self, adapter): + """GIFs are peeled off and sent individually via send_animation.""" + import telegram + telegram.InputMediaPhoto = MagicMock(side_effect=lambda media, caption=None: {"media": media}) + adapter.send_animation = AsyncMock() + # 2 photos + 1 gif + images = [ + ("https://x.com/a.png", ""), + ("https://x.com/b.gif", ""), + ("https://x.com/c.png", ""), + ] + _run(adapter.send_multiple_images("12345", images)) + + adapter.send_animation.assert_awaited_once() + assert adapter._bot.send_media_group.await_count == 1 + photos = adapter._bot.send_media_group.await_args.kwargs["media"] + assert len(photos) == 2 + + def test_fallback_to_per_image_on_send_media_group_failure(self, adapter): + """If send_media_group raises, each photo falls back to send_image.""" + import telegram + telegram.InputMediaPhoto = MagicMock(side_effect=lambda media, caption=None: {"media": media}) + adapter._bot.send_media_group = AsyncMock(side_effect=Exception("boom")) + adapter.send_image = AsyncMock(return_value=MagicMock(success=True)) + adapter.send_animation = AsyncMock(return_value=MagicMock(success=True)) + adapter.send_image_file = AsyncMock(return_value=MagicMock(success=True)) + + images = [(f"https://x.com/{i}.png", "") for i in range(3)] + _run(adapter.send_multiple_images("12345", images)) + + # Three per-image fallback calls + assert adapter.send_image.await_count == 3 + + def test_empty_noop(self, adapter): + _run(adapter.send_multiple_images("12345", [])) + adapter._bot.send_media_group.assert_not_called() + + +# --------------------------------------------------------------------------- +# Discord +# --------------------------------------------------------------------------- + + +def _ensure_discord_mock(): + if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"): + return + discord_mod = MagicMock() + discord_mod.Intents.default.return_value = MagicMock() + discord_mod.Client = MagicMock + discord_mod.File = MagicMock + for name in ("discord", "discord.ext", "discord.ext.commands"): + sys.modules.setdefault(name, discord_mod) + + +_ensure_discord_mock() + +from gateway.platforms.discord import DiscordAdapter # noqa: E402 + + +class TestDiscordMultiImage: + @pytest.fixture + def adapter(self): + config = PlatformConfig(enabled=True, token="fake-token") + a = DiscordAdapter(config) + a._client = MagicMock() + return a + + def test_single_batch_of_local_files_sends_once(self, adapter, tmp_path): + """3 local images → one channel.send with files=[...] of length 3.""" + paths = [] + for i in range(3): + p = tmp_path / f"img_{i}.png" + p.write_bytes(b"\x89PNG" + b"\x00" * 20) + paths.append(p) + + mock_channel = MagicMock() + mock_channel.send = AsyncMock(return_value=MagicMock(id=1)) + adapter._client.get_channel = MagicMock(return_value=mock_channel) + # Non-forum channel + adapter._is_forum_parent = MagicMock(return_value=False) + + images = [(f"file://{p}", "") for p in paths] + _run(adapter.send_multiple_images("67890", images)) + + mock_channel.send.assert_awaited_once() + assert len(mock_channel.send.call_args.kwargs["files"]) == 3 + + def test_batch_over_10_chunks_into_two_messages(self, adapter, tmp_path): + """15 local images → two channel.send calls (10 + 5).""" + paths = [] + for i in range(15): + p = tmp_path / f"img_{i}.png" + p.write_bytes(b"\x89PNG" + b"\x00" * 10) + paths.append(p) + + mock_channel = MagicMock() + mock_channel.send = AsyncMock(return_value=MagicMock(id=1)) + adapter._client.get_channel = MagicMock(return_value=mock_channel) + adapter._is_forum_parent = MagicMock(return_value=False) + + images = [(f"file://{p}", "") for p in paths] + _run(adapter.send_multiple_images("67890", images)) + + assert mock_channel.send.await_count == 2 + sizes = [len(c.kwargs["files"]) for c in mock_channel.send.await_args_list] + assert sizes == [10, 5] + + def test_empty_noop(self, adapter): + adapter._client = MagicMock() + _run(adapter.send_multiple_images("67890", [])) + + +# --------------------------------------------------------------------------- +# Slack +# --------------------------------------------------------------------------- + + +def _ensure_slack_mock(): + if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"): + return + slack_mod = MagicMock() + for name in ( + "slack_bolt", "slack_bolt.app", "slack_bolt.app.async_app", + "slack_bolt.adapter", "slack_bolt.adapter.socket_mode", + "slack_bolt.adapter.socket_mode.async_handler", + "slack_sdk", "slack_sdk.web", "slack_sdk.web.async_client", + "slack_sdk.errors", + ): + sys.modules.setdefault(name, slack_mod) + + +_ensure_slack_mock() + +from gateway.platforms.slack import SlackAdapter # noqa: E402 + + +class TestSlackMultiImage: + @pytest.fixture + def adapter(self): + config = PlatformConfig(enabled=True, token="xoxb-fake") + a = SlackAdapter(config) + a._app = MagicMock() + a._resolve_thread_ts = MagicMock(return_value=None) + a._record_uploaded_file_thread = MagicMock() + client = MagicMock() + client.files_upload_v2 = AsyncMock(return_value={"ok": True}) + a._get_client = MagicMock(return_value=client) + return a + + def test_single_batch_of_local_files_sends_one_upload(self, adapter, tmp_path): + paths = [] + for i in range(3): + p = tmp_path / f"img_{i}.png" + p.write_bytes(b"\x89PNG" + b"\x00" * 20) + paths.append(p) + + images = [(f"file://{p}", "") for p in paths] + _run(adapter.send_multiple_images("C12345", images)) + + client = adapter._get_client("C12345") + client.files_upload_v2.assert_awaited_once() + kwargs = client.files_upload_v2.await_args.kwargs + assert len(kwargs["file_uploads"]) == 3 + + def test_batch_over_10_chunks(self, adapter, tmp_path): + paths = [] + for i in range(12): + p = tmp_path / f"img_{i}.png" + p.write_bytes(b"\x89PNG" + b"\x00" * 5) + paths.append(p) + + images = [(f"file://{p}", "") for p in paths] + _run(adapter.send_multiple_images("C12345", images)) + + client = adapter._get_client("C12345") + assert client.files_upload_v2.await_count == 2 + sizes = [len(c.kwargs["file_uploads"]) for c in client.files_upload_v2.await_args_list] + assert sizes == [10, 2] + + def test_empty_noop(self, adapter): + _run(adapter.send_multiple_images("C12345", [])) + client = adapter._get_client("C12345") + client.files_upload_v2.assert_not_called() + + +# --------------------------------------------------------------------------- +# Mattermost +# --------------------------------------------------------------------------- + + +from gateway.platforms.mattermost import MattermostAdapter # noqa: E402 + + +class TestMattermostMultiImage: + @pytest.fixture + def adapter(self): + config = PlatformConfig(enabled=True, token="fake") + # Minimal construction via object.__new__ to avoid full setup + a = object.__new__(MattermostAdapter) + a._base_url = "https://mm.example.com" + a._token = "fake" + a._session = MagicMock() + a._reply_mode = "thread" + a._api_post = AsyncMock(return_value={"id": "post123"}) + a._upload_file = AsyncMock(side_effect=lambda *args, **kwargs: f"fid_{a._upload_file.await_count}") + return a + + def test_local_files_uploaded_and_single_post(self, adapter, tmp_path): + """3 local images → 3 uploads + 1 post with 3 file_ids.""" + paths = [] + for i in range(3): + p = tmp_path / f"img_{i}.png" + p.write_bytes(b"\x89PNG" + b"\x00" * 20) + paths.append(p) + + images = [(f"file://{p}", "") for p in paths] + _run(adapter.send_multiple_images("channel123", images)) + + assert adapter._upload_file.await_count == 3 + adapter._api_post.assert_awaited_once() + payload = adapter._api_post.await_args.args[1] + assert payload["channel_id"] == "channel123" + assert len(payload["file_ids"]) == 3 + + def test_batch_over_5_chunks(self, adapter, tmp_path): + """7 images → 2 posts (5 + 2).""" + paths = [] + for i in range(7): + p = tmp_path / f"img_{i}.png" + p.write_bytes(b"\x89PNG" + b"\x00" * 10) + paths.append(p) + + images = [(f"file://{p}", "") for p in paths] + _run(adapter.send_multiple_images("channel123", images)) + + assert adapter._api_post.await_count == 2 + sizes = [len(c.args[1]["file_ids"]) for c in adapter._api_post.await_args_list] + assert sizes == [5, 2] + + def test_empty_noop(self, adapter): + _run(adapter.send_multiple_images("channel123", [])) + adapter._api_post.assert_not_called() + + +# --------------------------------------------------------------------------- +# Email +# --------------------------------------------------------------------------- + + +from gateway.platforms.email import EmailAdapter # noqa: E402 + + +class TestEmailMultiImage: + @pytest.fixture + def adapter(self): + a = object.__new__(EmailAdapter) + a._address = "bot@example.com" + a._password = "secret" + a._smtp_host = "smtp.example.com" + a._smtp_port = 587 + a._thread_context = {} + return a + + def test_local_files_attached_in_single_email(self, adapter, tmp_path): + """3 local images → one SMTP send with 3 attachments.""" + paths = [] + for i in range(3): + p = tmp_path / f"img_{i}.png" + p.write_bytes(b"\x89PNG" + b"\x00" * 20) + paths.append(p) + + images = [(f"file://{p}", f"alt {i}") for i, p in enumerate(paths)] + + with patch.object( + adapter, "_send_email_with_attachments", MagicMock(return_value="") + ) as mock_send: + _run(adapter.send_multiple_images("user@example.com", images)) + + mock_send.assert_called_once() + to_addr, body, file_paths = mock_send.call_args.args + assert to_addr == "user@example.com" + assert len(file_paths) == 3 + assert "alt 0" in body + + def test_remote_urls_linked_in_body(self, adapter, tmp_path): + """Remote URL images get their URL appended to the body, no attachment.""" + images = [ + ("https://x.com/a.png", "first"), + ("https://x.com/b.png", "second"), + ] + with patch.object( + adapter, "_send_email_with_attachments", MagicMock(return_value="") + ) as mock_send: + _run(adapter.send_multiple_images("user@example.com", images)) + + mock_send.assert_called_once() + to_addr, body, file_paths = mock_send.call_args.args + assert file_paths == [] + assert "https://x.com/a.png" in body + assert "https://x.com/b.png" in body + + def test_empty_noop(self, adapter): + with patch.object( + adapter, "_send_email_with_attachments", MagicMock() + ) as mock_send: + _run(adapter.send_multiple_images("user@example.com", [])) + mock_send.assert_not_called()