From aeaa0c784eddaf4dbc1594e54069c883cacf3aa8 Mon Sep 17 00:00:00 2001 From: Andrew Bennett Date: Wed, 5 Aug 2026 09:10:36 -0500 Subject: [PATCH] feat(slack): add link unfurl controls --- cli-config.yaml.example | 4 ++++ plugins/platforms/slack/adapter.py | 24 ++++++++++++++++++- tests/gateway/test_slack.py | 24 +++++++++++++++++++ tests/gateway/test_slack_block_kit_adapter.py | 14 +++++++++++ website/docs/user-guide/messaging/slack.md | 8 +++++++ 5 files changed, 73 insertions(+), 1 deletion(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 1a8021ff98..8c5ef5bc7a 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -1214,6 +1214,10 @@ platform_toolsets: # # Render live tool calls as Slack-native plan/task cards. This explicit # # opt-in works even though Slack text tool_progress defaults to off. # native_task_cards: false +# # Suppress automatic link-preview cards without removing clickable links. +# # Omit either key to preserve Slack's default for that preview type. +# unfurl_links: false +# unfurl_media: false # webhook: # extra: # # Route scripts default to a 30 second timeout. Scripts must live under diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index 7debfb6616..f0153c1f29 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -79,6 +79,21 @@ _HERMES_SLACK_USER_AGENT_PREFIX = f"HermesAgent/{_HERMES_VERSION}" _SLACK_ERROR_BODY_LIMIT_BYTES = 8 * 1024 +def _slack_unfurl_kwargs(extra: Optional[Dict[str, Any]]) -> Dict[str, bool]: + """Return explicitly configured Slack link-preview controls. + + Omitting a key preserves Slack's existing default. Passing ``False`` + suppresses only the automatic preview while leaving the link text and URL + in the message untouched. + """ + settings = extra or {} + return { + key: settings[key] + for key in ("unfurl_links", "unfurl_media") + if isinstance(settings.get(key), bool) + } + + async def _read_error_text_limited( response: Any, *, @@ -2988,6 +3003,7 @@ class SlackAdapter(BasePlatformAdapter): "channel": chat_id, "text": chunk, "mrkdwn": True, + **_slack_unfurl_kwargs(self.config.extra), } if blocks and i == 0: kwargs["blocks"] = blocks @@ -9474,6 +9490,7 @@ async def _standalone_send( "channel": chat_id, "text": text_to_send, "mrkdwn": True, + **_slack_unfurl_kwargs(pconfig.extra), } if thread_id: post_kwargs["thread_ts"] = thread_id @@ -9589,7 +9606,12 @@ async def _standalone_send( async with aiohttp.ClientSession( timeout=aiohttp.ClientTimeout(total=30), **_sess_kw ) as session: - payload = {"channel": chat_id, "text": formatted, "mrkdwn": True} + payload = { + "channel": chat_id, + "text": formatted, + "mrkdwn": True, + **_slack_unfurl_kwargs(pconfig.extra), + } if thread_id: payload["thread_ts"] = thread_id for tok in tokens: diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index 5140130793..8312789ee5 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -1180,6 +1180,30 @@ class TestStandaloneSendUserDmResolution: assert session.post.call_count == 1 assert "chat.postMessage" in session.post.call_args.args[0] + @pytest.mark.asyncio + async def test_channel_delivery_honors_unfurl_config(self): + _slack_mod._slack_dm_cache.clear() + post_resp = self._mock_resp({"ok": True, "ts": "123.456"}) + session = self._mock_session(post_resp) + config = PlatformConfig( + enabled=True, + token="«redacted:xox…»", + extra={"unfurl_links": False, "unfurl_media": False}, + ) + + with patch.object(_slack_mod.aiohttp, "ClientSession", return_value=session): + result = await _slack_mod._standalone_send( + config, + "C123", + "[Hermes](https://example.com/hermes)", + ) + + assert result["success"] is True + payload = session.post.call_args.kwargs["json"] + assert payload["text"] == "" + assert payload["unfurl_links"] is False + assert payload["unfurl_media"] is False + @pytest.mark.asyncio async def test_user_id_media_delivery_resolves_dm_before_upload(self, tmp_path): diff --git a/tests/gateway/test_slack_block_kit_adapter.py b/tests/gateway/test_slack_block_kit_adapter.py index f77a1362b8..6e3291c13c 100644 --- a/tests/gateway/test_slack_block_kit_adapter.py +++ b/tests/gateway/test_slack_block_kit_adapter.py @@ -66,6 +66,20 @@ class TestSendMessageBlocks: assert "blocks" not in kwargs assert kwargs["text"] # plain text still sent + @pytest.mark.asyncio + async def test_unfurl_config_suppresses_previews_without_changing_link_text(self): + adapter, client = _make_adapter( + {"unfurl_links": False, "unfurl_media": False} + ) + content = "[Hermes](https://example.com/hermes)" + + await adapter.send("C1", content) + + kwargs = client.chat_postMessage.await_args.kwargs + assert kwargs["text"] == "" + assert kwargs["unfurl_links"] is False + assert kwargs["unfurl_media"] is False + @pytest.mark.asyncio async def test_enabled_but_unrenderable_falls_back_to_text(self): diff --git a/website/docs/user-guide/messaging/slack.md b/website/docs/user-guide/messaging/slack.md index 544ed727e9..cc327653d6 100644 --- a/website/docs/user-guide/messaging/slack.md +++ b/website/docs/user-guide/messaging/slack.md @@ -410,6 +410,12 @@ platforms: # Only the first chunk of the first reply is broadcast. reply_broadcast: false + # Control Slack's automatic link-preview cards without changing or + # removing clickable links from message text. Omit either key to keep + # Slack's default behavior for that preview type. + unfurl_links: false + unfurl_media: false + # Render agent messages as Slack Block Kit blocks (default: false). # When true, the final agent message is sent with structured blocks — # section headers, dividers, true nested lists (via rich_text), and @@ -458,6 +464,8 @@ platforms: | `platforms.slack.reply_to_mode` | `"first"` | Threading mode for multi-part messages: `"off"`, `"first"`, or `"all"` | | `platforms.slack.extra.reply_in_thread` | `true` | When `false`, channel messages get direct replies instead of threads. Messages inside existing threads still reply in-thread. | | `platforms.slack.extra.reply_broadcast` | `false` | When `true`, thread replies are also posted to the main channel. Only the first chunk is broadcast. | +| `platforms.slack.extra.unfurl_links` | Slack default | Set to `false` to suppress automatic previews for linked web pages while preserving clickable links. | +| `platforms.slack.extra.unfurl_media` | Slack default | Set to `false` to suppress automatic media previews while preserving clickable links. | | `platforms.slack.extra.rich_blocks` | `false` | When `true`, agent messages are rendered as [Block Kit](https://docs.slack.dev/block-kit/) blocks (headers, dividers, true nested lists, and native tables). A plain-text fallback is always sent. Tables over Slack's limits fall back to aligned monospace. No app reinstall required — it's a send-side change only. | | `platforms.slack.extra.feedback_buttons` | `false` | When `true` with `rich_blocks`, appends Slack-native feedback controls to final replies. | | `platforms.slack.extra.native_task_cards` | `false` | When `true`, renders live tool calls as Slack-native plan/task cards. This is an explicit progress opt-in independent of Slack's default `tool_progress: off`; native API failures fall back to one continuously edited text update. |