From ee62aab1a76ab6e543e58583e33f33737b83ef93 Mon Sep 17 00:00:00 2001 From: vexclawx31 <262373281+vexclawx31@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:42:49 +0200 Subject: [PATCH] fix(slack): honor disable_dms setting --- plugins/platforms/slack/adapter.py | 40 +++++++++++++++++++++++++++--- tests/gateway/test_config.py | 17 +++++++++++++ tests/gateway/test_slack.py | 39 +++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index 02c3ae27cf81..b0080a351340 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -5423,6 +5423,13 @@ class SlackAdapter(BasePlatformAdapter): if not channel_type and channel_id.startswith("D"): channel_type = "im" is_dm = channel_type in {"im", "mpim"} # Both 1:1 and group DMs + if is_dm and self._slack_disable_dms(): + logger.info( + "[Slack] Ignoring DM because Slack DMs are disabled: channel=%s user=%s", + channel_id, + user_id, + ) + return # A 1:1 IM is a private conversation with a single human — mention-exempt # and safe to react to unconditionally, like any DM. An MPIM (group DM) # is a SHARED surface: multiple humans can see and trigger the bot, so it @@ -7627,6 +7634,13 @@ class SlackAdapter(BasePlatformAdapter): if thread_id: break is_dm = str(channel_id).startswith("D") + if is_dm and self._slack_disable_dms(): + logger.info( + "[Slack] Ignoring slash command from DM because Slack DMs are disabled: channel=%s user=%s", + channel_id, + user_id, + ) + return source = self.build_source( chat_id=channel_id, chat_type="dm" if is_dm else "group", @@ -8265,12 +8279,27 @@ class SlackAdapter(BasePlatformAdapter): return {part.strip() for part in s.split(",") if part.strip()} return set() + def _slack_disable_dms(self) -> bool: + """Return whether incoming Slack DMs should be ignored. + + Supports both profile config (``slack.disable_dms`` bridged into + ``PlatformConfig.extra``) and the environment override + ``SLACK_DISABLE_DMS``. Defaults to False for backward compatibility. + """ + raw = self.config.extra.get("disable_dms") + if raw is None: + raw = os.getenv("SLACK_DISABLE_DMS", "false") + if isinstance(raw, str): + return raw.strip().lower() in {"true", "1", "yes", "on"} + return bool(raw) + def _slack_allowed_channels(self) -> set: """Return the whitelist of channel IDs the bot will respond in. When non-empty, messages from channels NOT in this set are silently - ignored — even if the bot is @mentioned. DMs are never filtered. - Empty set means no restriction (fully backward compatible). + ignored — even if the bot is @mentioned. DMs are controlled separately + by ``_slack_disable_dms()``. Empty set means no channel restriction + (fully backward compatible). """ raw = self.config.extra.get("allowed_channels") if raw is None: @@ -8859,6 +8888,9 @@ def _apply_yaml_config(yaml_cfg: dict, slack_cfg: dict) -> dict | None: rtt = slack_cfg.get("reaction_trigger_target") if rtt is not None and not os.getenv("SLACK_REACTION_TRIGGER_TARGET"): os.environ["SLACK_REACTION_TRIGGER_TARGET"] = str(rtt) + + if "disable_dms" in slack_cfg and not os.getenv("SLACK_DISABLE_DMS"): + os.environ["SLACK_DISABLE_DMS"] = str(slack_cfg["disable_dms"]).lower() ac = slack_cfg.get("allowed_channels") if ac is not None and not os.getenv("SLACK_ALLOWED_CHANNELS"): if isinstance(ac, list): @@ -8901,8 +8933,8 @@ def register(ctx) -> None: # YAML→env config bridge — owns the translation of config.yaml slack: # keys (require_mention, strict_mention, ignore_other_user_mentions, # thread_require_mention, allow_bots, free_response_channels, - # reactions, allowed_channels) into SLACK_* env vars that the adapter - # reads via os.getenv(). Replaces the + # reactions, disable_dms, allowed_channels) into SLACK_* env vars that + # the adapter reads via os.getenv(). Replaces the # hardcoded block in gateway/config.py. Hook contract: #24849. apply_yaml_config_fn=_apply_yaml_config, # Auth env vars for _is_user_authorized() integration diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index 3d1cfea9641c..9254e9ada605 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -575,6 +575,23 @@ class TestLoadGatewayConfig: assert config.quick_commands == {"limits": {"type": "exec", "command": "echo ok"}} + def test_slack_disable_dms_config_sets_env_bridge(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "slack:\n" + " disable_dms: true\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("SLACK_DISABLE_DMS", raising=False) + + load_gateway_config() + + assert os.getenv("SLACK_DISABLE_DMS") == "true" + def test_typing_status_text_from_toplevel_platform_block(self, tmp_path, monkeypatch): """A top-level ``slack:`` block reaches PlatformConfig via the shared-key bridge (bridged into extra, then the from_dict extra diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index 54f56c32a4ec..773893c3d2c3 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -366,6 +366,20 @@ class TestSlashCommandSessionIsolation: assert event.source.user_id == "U123" assert event.source.thread_id == "1700000000.123456" + @pytest.mark.asyncio + async def test_disable_dms_drops_dm_slash_command(self, adapter): + adapter.config.extra["disable_dms"] = True + command = { + "text": "hello", + "user_id": "U123", + "channel_id": "D123", + "team_id": "T123", + } + + await adapter._handle_slash_command(command) + + adapter.handle_message.assert_not_awaited() + class TestSlackWorkspaceCollisionIsolation: @pytest.mark.asyncio @@ -2385,6 +2399,31 @@ class TestBangPrefixCommands: assert "quoted context" not in msg_event.text assert msg_event.message_type == MessageType.COMMAND + @pytest.mark.asyncio + async def test_disable_dms_drops_text_dm(self, adapter): + adapter.config.extra["disable_dms"] = True + + await adapter._handle_slack_message(self._make_event("hello from DM")) + + adapter.handle_message.assert_not_awaited() + + @pytest.mark.asyncio + async def test_disable_dms_does_not_drop_channel_mentions(self, adapter): + adapter.config.extra["disable_dms"] = True + + await adapter._handle_slack_message( + self._make_event( + "<@U_BOT> hello from channel", + channel_type="channel", + channel="C123", + ) + ) + + adapter.handle_message.assert_awaited_once() + msg_event = adapter.handle_message.await_args.args[0] + assert msg_event.source.chat_type == "group" + assert msg_event.source.chat_id == "C123" + # --------------------------------------------------------------------------- # TestIncomingDocumentHandling