diff --git a/gateway/config.py b/gateway/config.py index 6f30ee706430..2470c0c3445e 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -830,6 +830,8 @@ def load_gateway_config() -> GatewayConfig: bridged["reply_in_thread"] = platform_cfg["reply_in_thread"] if "require_mention" in platform_cfg: bridged["require_mention"] = platform_cfg["require_mention"] + if plat == Platform.SLACK and "unfurl" in platform_cfg: + bridged["unfurl"] = platform_cfg["unfurl"] if plat == Platform.TELEGRAM and "allowed_chats" in platform_cfg: bridged["allowed_chats"] = platform_cfg["allowed_chats"] if plat == Platform.TELEGRAM and "group_allowed_chats" in platform_cfg: diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 5accfdb41089..357807a4bb32 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -790,6 +790,10 @@ async def send( # reply_broadcast: also post thread replies to the main channel. # Controlled via platform config: gateway.slack.reply_broadcast broadcast = self.config.extra.get("reply_broadcast", False) + # When gateway.slack.unfurl is false, suppress Slack's link/media + # previews so posted URLs don't expand into preview cards. Defaults + # to true, preserving Slack's native unfurling behavior. + unfurl = self.config.extra.get("unfurl", True) for i, chunk in enumerate(chunks): kwargs = { @@ -797,6 +801,9 @@ async def send( "text": chunk, "mrkdwn": True, } + if not unfurl: + kwargs["unfurl_links"] = False + kwargs["unfurl_media"] = False if thread_ts: kwargs["thread_ts"] = thread_ts # Only broadcast the first chunk of the first reply diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index bc09279eec4e..6c27954a5a6a 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -15,7 +15,7 @@ import pytest -from gateway.config import Platform, PlatformConfig +from gateway.config import Platform, PlatformConfig, load_gateway_config from gateway.platforms.base import ( MessageEvent, MessageType, @@ -2458,6 +2458,27 @@ async def test_send_explicitly_enables_mrkdwn(self, adapter): kwargs = adapter._app.client.chat_postMessage.call_args.kwargs assert kwargs.get("mrkdwn") is True + @pytest.mark.asyncio + async def test_send_disables_url_previews_when_unfurl_off(self, adapter): + """With gateway.slack.unfurl=false, posts must suppress link/media + unfurling so URLs don't expand into noisy preview cards.""" + adapter.config.extra["unfurl"] = False + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"}) + await adapter.send("C123", "see https://example.com/article") + kwargs = adapter._app.client.chat_postMessage.call_args.kwargs + assert kwargs.get("unfurl_links") is False + assert kwargs.get("unfurl_media") is False + + @pytest.mark.asyncio + async def test_send_preserves_url_previews_by_default(self, adapter): + """Default behavior leaves Slack's unfurling untouched — no unfurl + flags are sent, so Slack applies its native preview behavior.""" + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"}) + await adapter.send("C123", "see https://example.com/article") + kwargs = adapter._app.client.chat_postMessage.call_args.kwargs + assert "unfurl_links" not in kwargs + assert "unfurl_media" not in kwargs + @pytest.mark.asyncio async def test_send_does_not_double_escape_entities(self, adapter): """Pre-escaped & in sent messages must not become &.""" @@ -3177,3 +3198,27 @@ async def test_no_contextvar_does_not_match_any_context(self, adapter): # the normal single-user case; the ContextVar path is the precise one. # The key invariant is: when the ContextVar IS set, it matches exactly. assert ctx is not None # fallback path finds the entry + + +class TestUnfurlConfigBridging: + """gateway.slack.unfurl must bridge from YAML into PlatformConfig.extra so + the send path can read it — guards the allowlist wiring in + load_gateway_config (a non-allowlisted key would be silently dropped).""" + + def _write_config(self, tmp_path, content: str): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text(content, encoding="utf-8") + return hermes_home + + def test_top_level_unfurl_false_bridges_to_extra(self, tmp_path, monkeypatch): + hermes_home = self._write_config(tmp_path, "slack:\n unfurl: false\n") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + cfg = load_gateway_config() + assert cfg.platforms[Platform.SLACK].extra.get("unfurl") is False + + def test_unfurl_absent_when_not_configured(self, tmp_path, monkeypatch): + hermes_home = self._write_config(tmp_path, "slack:\n require_mention: true\n") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + cfg = load_gateway_config() + assert "unfurl" not in cfg.platforms[Platform.SLACK].extra diff --git a/website/docs/user-guide/messaging/slack.md b/website/docs/user-guide/messaging/slack.md index db32fcc4dea1..c476d5852252 100644 --- a/website/docs/user-guide/messaging/slack.md +++ b/website/docs/user-guide/messaging/slack.md @@ -343,6 +343,26 @@ platforms: | `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. | +### Link Previews + +By default Slack expands posted URLs into preview cards ("unfurling"). For bots +that post a lot of links — news feeds, digests, alerts — this is often noise. +Set `unfurl: false` to suppress link and media previews on the bot's own posts: + +```yaml +platforms: + slack: + extra: + # Suppress Slack link/media previews on the bot's posts (default: true) + unfurl: false +``` + +The top-level `slack.unfurl` shorthand is also accepted. + +| Key | Default | Description | +|-----|---------|-------------| +| `platforms.slack.extra.unfurl` | `true` | When `false`, the bot's posts disable Slack's `unfurl_links`/`unfurl_media`, so posted URLs don't expand into preview cards. | + ### Session Isolation ```yaml