fix(slack): preserve media in standalone cron delivery

This commit is contained in:
kandotrun 2026-07-21 22:05:28 +09:00 • committed by Teknium
parent f279f7fcf1
commit 4f726ed467
4 changed files with 162 additions and 14 deletions

View file

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

View file

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

View file

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

View file

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