mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-03 02:11:48 +00:00
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:
parent
04ea895ffb
commit
3de8e21683
7 changed files with 1012 additions and 3 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
463
tests/gateway/test_send_multiple_images.py
Normal file
463
tests/gateway/test_send_multiple_images.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue