feat(slack): add link unfurl controls
This commit is contained in:
committed by
Ben Barclay
parent
29033a3fd5
commit
aeaa0c784e
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user