diff --git a/scripts/release.py b/scripts/release.py index eecee8a93e..0187024e8b 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -97,6 +97,7 @@ AUTHOR_MAP = { "ahmedsherif95@gmail.com": "asheriif", "liujinkun@bytedance.com": "liujinkun2025", "dmayhem93@gmail.com": "dmahan93", + "cdanis@gmail.com": "cdanis", "samherring99@gmail.com": "samherring99", "desaiaum08@gmail.com": "Aum08Desai", "shannon.sands.1979@gmail.com": "shannonsands", diff --git a/tests/tools/test_signal_media.py b/tests/tools/test_signal_media.py new file mode 100644 index 0000000000..ee483c0819 --- /dev/null +++ b/tests/tools/test_signal_media.py @@ -0,0 +1,208 @@ +"""Tests for Signal media delivery in send_message_tool.py.""" + +import asyncio +import sys +from pathlib import Path +from types import ModuleType +from unittest.mock import MagicMock, AsyncMock, patch + +import pytest + +from gateway.config import Platform + + +def _make_httpx_mock(): + """Create a mock httpx module with proper sync json().""" + + class AsyncBaseTransport: + pass + + class Proxy: + pass + + class MockResp: + status_code = 200 + def json(self): + return {"timestamp": 1234567890} + def raise_for_status(self): + pass + + class MockClient: + async def __aenter__(self): + return self + async def __aexit__(self, *a): + pass + async def post(self, *args, **kwargs): + return MockResp() + + httpx_mock = ModuleType("httpx") + httpx_mock.AsyncClient = lambda timeout=None: MockClient() + httpx_mock.AsyncBaseTransport = AsyncBaseTransport # Needed by Telegram adapter + httpx_mock.Proxy = Proxy # Needed by telegram-bot library + return httpx_mock + + +@pytest.fixture(autouse=True) +def inject_httpx(monkeypatch): + """Inject mock httpx into sys.modules before imports.""" + monkeypatch.setitem(sys.modules, "httpx", _make_httpx_mock()) + + +class TestSendSignalMediaFiles: + """Test that _send_signal correctly handles media_files parameter.""" + + def test_send_signal_basic_text_without_media(self): + """Backward compatibility: text-only signal messages work.""" + from tools.send_message_tool import _send_signal + + extra = {"http_url": "http://localhost:8080", "account": "+155****4567"} + + result = asyncio.run(_send_signal(extra, "+155****9999", "Hello world")) + + assert result["success"] is True + assert result["platform"] == "signal" + assert result["chat_id"] == "+155****9999" + + def test_send_signal_with_attachments(self, tmp_path): + """Signal messages with media_files include attachments in JSON-RPC.""" + from tools.send_message_tool import _send_signal + + img_path = tmp_path / "test.png" + img_path.write_bytes(b"\x89PNG") + + extra = {"http_url": "http://localhost:8080", "account": "+155****4567"} + + result = asyncio.run( + _send_signal(extra, "+155****9999", "Check this out", media_files=[(str(img_path), False)]) + ) + + assert result["success"] is True + assert result["platform"] == "signal" + + def test_send_signal_with_missing_media_file(self): + """Missing media files should generate warnings but not fail.""" + from tools.send_message_tool import _send_signal + + extra = {"http_url": "http://localhost:8080", "account": "+155****4567"} + + result = asyncio.run( + _send_signal(extra, "+155****9999", "File missing?", media_files=[("/nonexistent.png", False)]) + ) + + assert result["success"] is True # Should succeed despite missing file + assert "warnings" in result + assert "Some media files were skipped" in str(result["warnings"]) + + +class TestSendSignalMediaRestrictions: + """Test that the restriction block handles Signal media correctly.""" + + def test_signal_allows_text_only_media_via_send_to_platform(self): + """Signal should accept text-only media files (no message) via _send_to_platform.""" + import httpx + if not hasattr(httpx, 'Proxy') or not hasattr(httpx, 'URL'): + pytest.skip("httpx type annotations incompatible with telegram library") + from tools.send_message_tool import _send_to_platform + + mock_result = {"success": True, "platform": "signal"} + with patch("tools.send_message_tool._send_signal", new=AsyncMock(return_value=mock_result)): + config = MagicMock() + config.platforms = {Platform.SIGNAL: MagicMock(enabled=True)} + config.get_home_channel.return_value = None + + result = asyncio.run( + _send_to_platform( + Platform.SIGNAL, + config, + "+155****9999", + "", # Empty message - media is the message + media_files=[("/tmp/test.png", False)] + ) + ) + + assert result["success"] is True + + def test_non_media_platforms_reject_text_only_media(self): + """Slack should reject text-only media (no MESSAGE content).""" + import httpx + if not hasattr(httpx, 'Proxy') or not hasattr(httpx, 'URL'): + pytest.skip("httpx type annotations incompatible with telegram library") + from tools.send_message_tool import _send_to_platform + + config = MagicMock() + config.platforms = {Platform.SLACK: MagicMock(enabled=True)} + config.get_home_channel.return_value = None + + # Empty message with media_files should trigger restriction block + result = asyncio.run( + _send_to_platform( + Platform.SLACK, + config, + "C012AB3CD", + "", # Empty message - media is the only content + media_files=[("/tmp/test.png", False)] + ) + ) + + assert "error" in result + assert "only supported for" in result["error"] + + +class TestSendSignalMediaWarningMessages: + """Test warning messages are updated to include signal.""" + + def test_warning_includes_signal_when_media_omitted(self): + """Non-media platforms should show a warning mentioning signal in the supported list.""" + import httpx + if not hasattr(httpx, 'Proxy') or not hasattr(httpx, 'URL'): + pytest.skip("httpx type annotations incompatible with telegram library") + from tools.send_message_tool import _send_to_platform + + config = MagicMock() + config.platforms = {Platform.SLACK: MagicMock(enabled=True)} + config.get_home_channel.return_value = None + + # Mock _send_slack so it succeeds -> then warning gets attached to result + with patch("tools.send_message_tool._send_slack", new=AsyncMock(return_value={"success": True})): + result = asyncio.run( + _send_to_platform( + Platform.SLACK, + config, + "C012AB3CD", + "Test message with media", + media_files=[("/tmp/test.png", False)] + ) + ) + + assert result.get("warnings") is not None + # Check that the warning mentions signal as supported + found = any("signal" in w.lower() for w in result["warnings"]) + assert found, f"Expected 'signal' in warnings but got: {result.get('warnings')}" + + +class TestSendSignalGroupChats: + """Test that _send_signal handles group chats correctly.""" + + def test_send_signal_group_with_attachments(self, tmp_path): + """Group chat messages with attachments should use groupId parameter.""" + from tools.send_message_tool import _send_signal + + img_path = tmp_path / "test_attachment.pdf" + img_path.write_bytes(b"%PDF-1.4") + + extra = {"http_url": "http://localhost:8080", "account": "+155****4567"} + + result = asyncio.run( + _send_signal(extra, "group:abc123==", "Group file", media_files=[(str(img_path), False)]) + ) + + assert result["success"] is True + + +class TestSendSignalConfigLoading: + """Verify Signal config loading works.""" + + def test_signal_platform_exists(self): + """Platform.SIGNAL should be a valid platform.""" + assert hasattr(Platform, "SIGNAL") + assert Platform.SIGNAL.value == "signal" diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 1a344c5341..dacc7e17ab 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -514,11 +514,27 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, last_result = result return last_result - # --- Non-Telegram/Discord platforms --- + # --- Signal: native attachment support via JSON-RPC attachments param --- + if platform == Platform.SIGNAL and media_files: + last_result = None + for i, chunk in enumerate(chunks): + is_last = (i == len(chunks) - 1) + result = await _send_signal( + pconfig.extra, + chat_id, + chunk, + media_files=media_files if is_last else [], + ) + if isinstance(result, dict) and result.get("error"): + return result + last_result = result + return last_result + + # --- Non-media platforms --- if media_files and not message.strip(): return { "error": ( - f"send_message MEDIA delivery is currently only supported for telegram, discord, matrix, and weixin; " + f"send_message MEDIA delivery is currently only supported for telegram, discord, matrix, weixin, and signal; " f"target {platform.value} had only media attachments" ) } @@ -526,7 +542,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, if media_files: warning = ( f"MEDIA attachments were omitted for {platform.value}; " - "native send_message media delivery is currently only supported for telegram, discord, matrix, and weixin" + "native send_message media delivery is currently only supported for telegram, discord, matrix, weixin, and signal" ) last_result = None @@ -972,8 +988,12 @@ async def _send_whatsapp(extra, chat_id, message): return _error(f"WhatsApp send failed: {e}") -async def _send_signal(extra, chat_id, message): - """Send via signal-cli JSON-RPC API.""" +async def _send_signal(extra, chat_id, message, media_files=None): + """Send via signal-cli JSON-RPC API. + + Supports both text-only and text-with-attachments (images/audio/documents). + Attachments are sent as an 'attachments' array in the JSON-RPC params. + """ try: import httpx except ImportError: @@ -990,6 +1010,18 @@ async def _send_signal(extra, chat_id, message): else: params["recipient"] = [chat_id] + # Add attachments if media_files are present + valid_media = media_files or [] + attachment_paths = [] + for media_path, _is_voice in valid_media: + if os.path.exists(media_path): + attachment_paths.append(media_path) + else: + logger.warning("Signal media file not found, skipping: %s", media_path) + + if attachment_paths: + params["attachments"] = attachment_paths + payload = { "jsonrpc": "2.0", "method": "send", @@ -1003,7 +1035,12 @@ async def _send_signal(extra, chat_id, message): data = resp.json() if "error" in data: return _error(f"Signal RPC error: {data['error']}") - return {"success": True, "platform": "signal", "chat_id": chat_id} + + # Return warning for any skipped media files + result = {"success": True, "platform": "signal", "chat_id": chat_id} + if len(attachment_paths) < len(valid_media): + result["warnings"] = [f"Some media files were skipped (not found on disk)"] + return result except Exception as e: return _error(f"Signal send failed: {e}")