feat(slack): add link unfurl controls

This commit is contained in:
Andrew Bennett
2026-08-05 09:10:36 -05:00
committed by Ben Barclay
parent 29033a3fd5
commit aeaa0c784e
5 changed files with 73 additions and 1 deletions

View File

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

View File

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

View File

@@ -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"] == "<https://example.com/hermes|Hermes>"
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):

View File

@@ -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"] == "<https://example.com/hermes|Hermes>"
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):

View File

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