From 2d9260cb9f857fcf987116290f954487b1a323a7 Mon Sep 17 00:00:00 2001 From: brendanlevy Date: Wed, 10 Jun 2026 13:38:37 -0700 Subject: [PATCH] feat(slack): add groupRequireMention for allowlist channels Slack's groupPolicy could either restrict to specific channels ("allowlist") or require an @mention ("mention"), but not both: in allowlist mode the bot replied to every message in approved channels. Add a groupRequireMention flag so that, when groupPolicy is "allowlist", the bot only responds in channels listed in groupAllowFrom AND only when @mentioned. Mirrors Signal's group.requireMention. No effect for the "mention"/"open" policies, so existing configs are unchanged. Extract the mention check into _is_mention and reuse it from both the mention and allowlist branches. Co-authored-by: Cursor --- docs/chat-apps.md | 4 +- nanobot/channels/slack.py | 19 +++++++-- tests/channels/test_slack_channel.py | 59 ++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/docs/chat-apps.md b/docs/chat-apps.md index 068e7edfc..f23ed7b91 100644 --- a/docs/chat-apps.md +++ b/docs/chat-apps.md @@ -572,7 +572,9 @@ nanobot gateway DM the bot directly or @mention it in a channel — it should respond! > [!TIP] -> - `groupPolicy`: `"mention"` (default — respond only when @mentioned), `"open"` (respond to all channel messages), or `"allowlist"` (restrict to specific channels). +> - `groupPolicy`: `"mention"` (default — respond only when @mentioned), `"open"` (respond to all channel messages), or `"allowlist"` (restrict to specific channels via `groupAllowFrom`). +> - `groupAllowFrom`: channel IDs the bot may respond in when `groupPolicy` is `"allowlist"`. +> - `groupRequireMention`: when `true` and `groupPolicy` is `"allowlist"`, the bot only replies to channels in `groupAllowFrom` **and** only when @mentioned (instead of every message). No effect for `"mention"`/`"open"`. Use this to scope the bot to approved channels while keeping mention-only behavior. > - DM policy defaults to open. Set `"dm": {"enabled": false}` to disable DMs. diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 757b05f20..45aa21179 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -47,6 +47,10 @@ class SlackConfig(Base): allow_from: list[str] = Field(default_factory=list) group_policy: str = "mention" group_allow_from: list[str] = Field(default_factory=list) + # When group_policy is "allowlist", also require the bot to be @mentioned + # before responding (so it only replies to mentions in approved channels, + # instead of every message). No effect for "mention"/"open" policies. + group_require_mention: bool = False dm: SlackDMConfig = Field(default_factory=SlackDMConfig) @@ -648,15 +652,22 @@ class SlackChannel(BaseChannel): return chat_id in self.config.group_allow_from return True + def _is_mention(self, event_type: str, text: str) -> bool: + if event_type == "app_mention": + return True + return self._bot_user_id is not None and f"<@{self._bot_user_id}>" in text + def _should_respond_in_channel(self, event_type: str, text: str, chat_id: str) -> bool: if self.config.group_policy == "open": return True if self.config.group_policy == "mention": - if event_type == "app_mention": - return True - return self._bot_user_id is not None and f"<@{self._bot_user_id}>" in text + return self._is_mention(event_type, text) if self.config.group_policy == "allowlist": - return chat_id in self.config.group_allow_from + if chat_id not in self.config.group_allow_from: + return False + if self.config.group_require_mention: + return self._is_mention(event_type, text) + return True return False def is_allowed(self, sender_id: str) -> bool: diff --git a/tests/channels/test_slack_channel.py b/tests/channels/test_slack_channel.py index d0f41766a..ba8275eb3 100644 --- a/tests/channels/test_slack_channel.py +++ b/tests/channels/test_slack_channel.py @@ -655,3 +655,62 @@ def test_slack_channel_uses_channel_aware_allow_policy() -> None: channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus()) assert channel.is_allowed("U1") is True assert channel._is_allowed("U1", "C123", "channel") is True + + +def test_mention_policy_responds_to_mentions_in_any_channel() -> None: + channel = SlackChannel(SlackConfig(enabled=True, group_policy="mention"), MessageBus()) + channel._bot_user_id = "UBOT" + + assert channel._should_respond_in_channel("app_mention", "<@UBOT> hi", "C123") is True + assert channel._should_respond_in_channel("message", "<@UBOT> hi", "C999") is True + assert channel._should_respond_in_channel("message", "no mention here", "C123") is False + + +def test_allowlist_policy_restricts_to_approved_channels() -> None: + channel = SlackChannel( + SlackConfig(enabled=True, group_policy="allowlist", group_allow_from=["C_OK"]), + MessageBus(), + ) + channel._bot_user_id = "UBOT" + + # In an approved channel without require_mention, respond to anything. + assert channel._should_respond_in_channel("message", "anything", "C_OK") is True + # An unapproved channel is always rejected. + assert channel._should_respond_in_channel("app_mention", "<@UBOT> hi", "C_NOPE") is False + # _is_allowed also gates on the channel allowlist. + assert channel._is_allowed("U1", "C_OK", "channel") is True + assert channel._is_allowed("U1", "C_NOPE", "channel") is False + + +def test_allowlist_with_require_mention_needs_both_channel_and_mention() -> None: + channel = SlackChannel( + SlackConfig( + enabled=True, + group_policy="allowlist", + group_allow_from=["C_OK"], + group_require_mention=True, + ), + MessageBus(), + ) + channel._bot_user_id = "UBOT" + + # Approved channel + mention -> respond. + assert channel._should_respond_in_channel("app_mention", "<@UBOT> hi", "C_OK") is True + assert channel._should_respond_in_channel("message", "<@UBOT> hi", "C_OK") is True + # Approved channel but no mention -> stay quiet. + assert channel._should_respond_in_channel("message", "just chatting", "C_OK") is False + # Mention in an unapproved channel -> stay quiet. + assert channel._should_respond_in_channel("app_mention", "<@UBOT> hi", "C_NOPE") is False + + +def test_group_require_mention_accepts_camel_case_alias() -> None: + config = SlackConfig.model_validate( + { + "enabled": True, + "groupPolicy": "allowlist", + "groupAllowFrom": ["C_OK"], + "groupRequireMention": True, + } + ) + assert config.group_require_mention is True + assert config.group_allow_from == ["C_OK"]