feat(gateway): native send_multiple_images for Telegram, Discord, Slack, Mattermost, Email

Ports PR #17888's send_multiple_images ABC to every gateway platform that
has a native multi-attachment API, so images arrive as a single bundled
message instead of N separate ones.

Native overrides:
- Telegram: send_media_group (10 photos per album, chunks over); animated
  GIFs peeled off and routed through send_animation (albums don't support
  animations)
- Discord: channel.send(files=[...]) (10 attachments per message, chunks
  over); URL images downloaded into BytesIO so they render inline; forum
  channels use create_thread with files=[...]
- Slack: files_upload_v2(file_uploads=[...]) (10 per call, chunks over);
  respects thread_ts; records thread participation
- Mattermost: single post with file_ids list (5 per post — Mattermost cap,
  chunks over)
- Email: single SMTP message with multiple MIME attachments (no chunk cap,
  SMTP size governs); remote URLs remain linked in body (parity with
  existing send_image)

All platforms fall back to the base per-image loop on any failure, so a
single bad image in a batch never loses the rest.

Matrix, WhatsApp, and single-attachment platforms (BlueBubbles, Feishu,
WeCom, WeChat, DingTalk) continue to use the base default loop — their
server APIs only accept one attachment per message anyway.

Tests: adds tests/gateway/test_send_multiple_images.py with 19 targeted
tests covering base default loop, chunking, animation peel-off, fallback
paths, and empty-batch no-ops across all five new overrides.

Co-authored-by: Maxence Groine <maxence@groine.fr>
This commit is contained in:
Teknium 2026-04-30 03:39:06 -07:00
parent 04ea895ffb
commit 3de8e21683
7 changed files with 1012 additions and 3 deletions

View file

@ -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,

View file

@ -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"<hermes-{uuid.uuid4().hex[:12]}@{self._address.split('@')[1]}>"
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,

View file

@ -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
# ------------------------------------------------------------------

View file

@ -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:

View file

@ -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,

View file

@ -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",

View file

@ -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="<msgid@x>")
) 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="<msgid@x>")
) 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()