hermes-agent/tests/gateway/test_send_multiple_images.py

721 lines
26 KiB
Python

"""
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 sys
import types
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, *, is_reconnect: bool = False):
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 plugins.platforms.telegram.adapter 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 plugins.platforms.discord.adapter 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", []))
def test_url_batch_blocks_private_redirect_before_send(self, adapter, monkeypatch):
"""A public image URL must not redirect into private metadata and then upload."""
import plugins.platforms.discord.adapter as discord_adapter
public_url = "https://cdn.example.test/image.png"
private_url = "http://169.254.169.254/latest/meta-data/"
safe_calls = []
def fake_is_safe_url(url):
safe_calls.append(url)
return not str(url).startswith("http://169.254.169.254")
class FakeResponse:
status = 302
headers = {"location": private_url}
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def read(self):
return b"metadata-secret"
class FakeSession:
def get(self, url, **kwargs):
assert kwargs.get("allow_redirects") is False
return FakeResponse()
async def close(self):
return None
fake_aiohttp = types.SimpleNamespace(
ClientSession=lambda **kwargs: FakeSession(),
ClientTimeout=lambda **kwargs: kwargs,
)
monkeypatch.setitem(sys.modules, "aiohttp", fake_aiohttp)
monkeypatch.setattr(discord_adapter, "is_safe_url", fake_is_safe_url)
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)
_run(adapter.send_multiple_images("67890", [(public_url, "caption")]))
mock_channel.send.assert_not_awaited()
assert private_url in safe_calls
def test_url_batch_follows_safe_redirect_location_header(self, adapter, monkeypatch):
"""Redirect handling preserves aiohttp's case-insensitive Location behavior."""
import plugins.platforms.discord.adapter as discord_adapter
public_url = "https://cdn.example.test/image.png"
redirected_url = "https://assets.example.test/image.png"
requested_urls = []
class RedirectResponse:
status = 302
headers = {"Location": redirected_url}
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def read(self):
raise AssertionError("redirect responses must not be read")
class ImageResponse:
status = 200
headers = {"Content-Type": "image/png"}
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def read(self):
return b"\x89PNG"
class FakeSession:
def get(self, url, **kwargs):
assert kwargs.get("allow_redirects") is False
requested_urls.append(url)
if url == public_url:
return RedirectResponse()
assert url == redirected_url
return ImageResponse()
async def close(self):
return None
fake_aiohttp = types.SimpleNamespace(
ClientSession=lambda **kwargs: FakeSession(),
ClientTimeout=lambda **kwargs: kwargs,
)
monkeypatch.setitem(sys.modules, "aiohttp", fake_aiohttp)
monkeypatch.setattr(discord_adapter, "is_safe_url", lambda url: True)
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)
_run(adapter.send_multiple_images("67890", [(public_url, "caption")]))
assert requested_urls == [public_url, redirected_url]
mock_channel.send.assert_awaited_once()
def test_send_image_blocks_private_redirect_before_send(self, adapter, monkeypatch):
import plugins.platforms.discord.adapter as discord_adapter
public_url = "https://cdn.example.test/image.png"
private_url = "http://169.254.169.254/latest/meta-data/"
class FakeResponse:
status = 302
headers = {"Location": private_url}
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def read(self):
return b"metadata-secret"
class FakeSession:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
def get(self, url, **kwargs):
assert kwargs.get("allow_redirects") is False
return FakeResponse()
fake_aiohttp = types.SimpleNamespace(
ClientSession=lambda **kwargs: FakeSession(),
ClientTimeout=lambda **kwargs: kwargs,
)
monkeypatch.setitem(sys.modules, "aiohttp", fake_aiohttp)
monkeypatch.setattr(
discord_adapter,
"is_safe_url",
lambda url: not str(url).startswith("http://169.254.169.254"),
)
adapter._is_forum_parent = MagicMock(return_value=False)
mock_channel = MagicMock()
mock_channel.send = AsyncMock(return_value=MagicMock(id=1))
adapter._client.get_channel = MagicMock(return_value=mock_channel)
adapter._client.fetch_channel = AsyncMock(return_value=mock_channel)
adapter.send = AsyncMock()
_run(adapter.send_image("67890", public_url, "caption"))
mock_channel.send.assert_not_awaited()
def test_send_animation_blocks_private_redirect_before_send(self, adapter, monkeypatch):
import plugins.platforms.discord.adapter as discord_adapter
public_url = "https://cdn.example.test/animation.gif"
private_url = "http://169.254.169.254/latest/meta-data/"
class FakeResponse:
status = 302
headers = {"Location": private_url}
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def read(self):
return b"metadata-secret"
class FakeSession:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
def get(self, url, **kwargs):
assert kwargs.get("allow_redirects") is False
return FakeResponse()
fake_aiohttp = types.SimpleNamespace(
ClientSession=lambda **kwargs: FakeSession(),
ClientTimeout=lambda **kwargs: kwargs,
)
monkeypatch.setitem(sys.modules, "aiohttp", fake_aiohttp)
monkeypatch.setattr(
discord_adapter,
"is_safe_url",
lambda url: not str(url).startswith("http://169.254.169.254"),
)
adapter._is_forum_parent = MagicMock(return_value=False)
mock_channel = MagicMock()
mock_channel.send = AsyncMock(return_value=MagicMock(id=1))
adapter._client.get_channel = MagicMock(return_value=mock_channel)
adapter._client.fetch_channel = AsyncMock(return_value=mock_channel)
adapter.send = AsyncMock()
_run(adapter.send_animation("67890", public_url, "caption"))
mock_channel.send.assert_not_awaited()
# ---------------------------------------------------------------------------
# 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 plugins.platforms.slack.adapter 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()
def test_url_batch_blocks_private_redirect_before_upload(self, adapter, monkeypatch):
"""HTTP redirects are rechecked before Slack batch uploads remote bytes."""
import httpx
import tools.url_safety as url_safety
public_url = "https://cdn.example.test/image.png"
private_url = "http://169.254.169.254/latest/meta-data/"
safe_calls = []
def fake_is_safe_url(url):
safe_calls.append(url)
return not str(url).startswith("http://169.254.169.254")
class RedirectResponse:
is_redirect = True
url = public_url
headers = {"location": private_url}
next_request = None
class FakeAsyncClient:
def __init__(self, **kwargs):
self.event_hooks = kwargs.get("event_hooks", {})
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def get(self, url):
for hook in self.event_hooks.get("response", []):
await hook(RedirectResponse())
raise AssertionError("private redirect was not blocked before fetch")
monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
monkeypatch.setattr(url_safety, "is_safe_url", fake_is_safe_url)
_run(adapter.send_multiple_images("C12345", [(public_url, "caption")]))
client = adapter._get_client("C12345")
client.files_upload_v2.assert_not_called()
assert private_url in safe_calls
# ---------------------------------------------------------------------------
# Mattermost
# ---------------------------------------------------------------------------
from plugins.platforms.mattermost.adapter 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 plugins.platforms.email.adapter 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()