diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index 24cc0d8cb11..bc4ed11cb3c 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -6507,6 +6507,58 @@ async def _standalone_send( exc_info=True, ) + # Out-of-process cron runs have no live SlackAdapter. Upload MEDIA files + # here so the standalone path has feature parity with the live adapter + # instead of silently succeeding with text only. This runs BEFORE the + # empty-text skip: a media delivery with a blank caption must still + # upload the files. + if media_files: + if not check_slack_requirements(): + return {"error": "Slack send failed: slack-sdk is not installed"} + + paths = [] + for media in media_files: + path = media[0] if isinstance(media, (tuple, list)) else media + path = str(path) + if not os.path.isfile(path): + return {"error": f"Slack media file not found: {os.path.basename(path)}"} + paths.append(path) + + try: + client = AsyncWebClient(token=token) # pyright: ignore[reportCallIssue] + _apply_slack_proxy(client, resolve_proxy_url()) + upload_result = None + for start in range(0, len(paths), 10): + batch = paths[start : start + 10] + kwargs = { + "channel": chat_id, + "initial_comment": formatted if start == 0 else "", + "thread_ts": thread_id, + } + if len(batch) == 1: + kwargs.update( + file=batch[0], + filename=os.path.basename(batch[0]), + ) + else: + kwargs["file_uploads"] = [ + {"file": path, "filename": os.path.basename(path)} + for path in batch + ] + upload_result = await client.files_upload_v2(**kwargs) + return { + "success": True, + "platform": "slack", + "chat_id": chat_id, + "message_id": ( + upload_result.get("ts") + if upload_result is not None and hasattr(upload_result, "get") + else None + ), + } + except Exception as e: + return {"error": f"Slack media upload failed: {e}"} + if not formatted or not formatted.strip(): logger.debug("[Slack] _standalone_send: skipping empty/whitespace message") return { @@ -6521,7 +6573,7 @@ async def _standalone_send( return {"error": "aiohttp not installed. Run: pip install aiohttp"} try: - from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp + from gateway.platforms.base import proxy_kwargs_for_aiohttp _proxy = resolve_proxy_url() _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy) diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index f5548b41c93..5096c79c0d6 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -1207,6 +1207,50 @@ class TestSlackProxyBehavior: assert adapter._handler.client.proxy is None +# --------------------------------------------------------------------------- +# TestStandaloneSendMedia +# --------------------------------------------------------------------------- + + +class TestStandaloneSendMedia: + @pytest.mark.asyncio + async def test_uploads_local_media_with_message_as_caption(self, tmp_path): + """Standalone cron sends should use files_upload_v2, not omit the image.""" + image = tmp_path / "daily-report.png" + image.write_bytes(b"\x89PNG\r\n\x1a\n") + client = MagicMock() + client.files_upload_v2 = AsyncMock( + return_value={"ok": True, "files": [{"id": "F123"}]} + ) + config = PlatformConfig(enabled=True, token="xoxb-fake-token") + + with ( + patch.object(_slack_mod, "AsyncWebClient", return_value=client), + patch.object(_slack_mod, "resolve_proxy_url", return_value=None), + patch.object( + _slack_mod.aiohttp, + "ClientSession", + side_effect=AssertionError("media delivery used text-only chat.postMessage"), + ), + ): + result = await _slack_mod._standalone_send( + config, + "C123", + "daily report", + thread_id=None, + media_files=[(str(image), False)], + ) + + assert result["success"] is True + client.files_upload_v2.assert_awaited_once_with( + channel="C123", + file=str(image), + filename="daily-report.png", + initial_comment="daily report", + thread_ts=None, + ) + + # --------------------------------------------------------------------------- # TestSendDocument # --------------------------------------------------------------------------- diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 55c5165901b..02e4ac0f33c 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -755,6 +755,44 @@ class TestSendToPlatformChunking: thread_ts=None, ) + def test_slack_media_is_forwarded_to_standalone_plugin(self, monkeypatch, tmp_path): + """Out-of-process cron delivery must not silently drop Slack MEDIA files.""" + _ensure_slack_mock(monkeypatch) + media_path = tmp_path / "daily-report.png" + media_path.write_bytes(b"\x89PNG\r\n\x1a\n") + media_files = [(str(media_path), False)] + pconfig = SimpleNamespace(enabled=True, token="***", extra={}) + + entry = _slack_entry() + assert entry is not None + original = entry.standalone_sender_fn + send = AsyncMock( + return_value={"success": True, "platform": "slack", "message_id": "1"} + ) + entry.standalone_sender_fn = send + try: + result = asyncio.run( + _send_to_platform( + Platform.SLACK, + pconfig, + "C123", + "daily report", + media_files=media_files, + ) + ) + finally: + entry.standalone_sender_fn = original + + assert result["success"] is True + send.assert_awaited_once_with( + pconfig, + "C123", + "daily report", + thread_id=None, + media_files=media_files, + force_document=False, + ) + def test_slack_bold_italic_formatted_before_send(self, monkeypatch): """Bold+italic ***text*** survives tool-layer formatting.""" _ensure_slack_mock(monkeypatch) diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 6cccee7eddf..9225e199ccd 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -1032,6 +1032,32 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, last_result = result return last_result + # --- Slack: route both text and native files through the plugin's + # standalone sender. This path is used by out-of-process cron runs where + # no live gateway adapter is available; dropping ``media_files`` here made + # MEDIA directives disappear while the text delivery still reported + # success. + if platform == Platform.SLACK: + from gateway.platform_registry import platform_registry + entry = platform_registry.get("slack") + if entry is None or entry.standalone_sender_fn is None: + return {"error": "Slack plugin not registered or missing standalone_sender_fn"} + last_result = None + for i, chunk in enumerate(chunks): + is_last = i == len(chunks) - 1 + result = await entry.standalone_sender_fn( + pconfig, + chat_id, + chunk, + thread_id=thread_id, + media_files=media_files if is_last else [], + force_document=force_document, + ) + 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 { @@ -1049,19 +1075,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, last_result = None for chunk in chunks: - if platform == Platform.SLACK: - # Slack migrated to a bundled plugin (#41112); delivery flows - # through the registry's standalone_sender_fn, which applies - # mrkdwn formatting and posts via the Slack Web API. - from gateway.platform_registry import platform_registry - _slack_entry = platform_registry.get("slack") - if _slack_entry is None or _slack_entry.standalone_sender_fn is None: - result = {"error": "Slack plugin not registered or missing standalone_sender_fn"} - else: - result = await _slack_entry.standalone_sender_fn( - pconfig, chat_id, chunk, thread_id=thread_id - ) - elif platform == Platform.WHATSAPP: + if platform == Platform.WHATSAPP: result = await _registry_standalone_send("whatsapp", pconfig, chat_id, chunk, thread_id) elif platform == Platform.SIGNAL: result = await _send_signal(pconfig.extra, chat_id, chunk)