From fd3ac4792514383b671d0f2637241bb049a809ad Mon Sep 17 00:00:00 2001 From: Nikolas Garza <90273783+nmgarza5@users.noreply.github.com> Date: Fri, 12 Jun 2026 13:58:47 -0700 Subject: [PATCH] feat(slack-connector): add channel exclude list with optional regex (#11996) --- backend/onyx/connectors/slack/connector.py | 110 ++++++++++---- .../slack/test_slack_filter_channels.py | 136 ++++++++++++++++++ .../admin/connectors/ConnectorTitle.tsx | 14 ++ web/src/lib/connectors/connectors.tsx | 21 +++ 4 files changed, 255 insertions(+), 26 deletions(-) create mode 100644 backend/tests/unit/onyx/connectors/slack/test_slack_filter_channels.py diff --git a/backend/onyx/connectors/slack/connector.py b/backend/onyx/connectors/slack/connector.py index 2548fe5abb..2868229461 100644 --- a/backend/onyx/connectors/slack/connector.py +++ b/backend/onyx/connectors/slack/connector.py @@ -408,27 +408,52 @@ def _bot_inclusive_msg_filter( def filter_channels( all_channels: list[ChannelType], - channels_to_connect: list[str] | None, - regex_enabled: bool, + channels_to_include: list[str] | None, + include_regex_enabled: bool, + channels_to_exclude: list[str] | None = None, + exclude_regex_enabled: bool = False, ) -> list[ChannelType]: - if not channels_to_connect: - return all_channels + filtered_channels = all_channels - if regex_enabled: - return [ + if channels_to_include: + if not include_regex_enabled: + _validate_channels_exist(all_channels, channels_to_include) + filtered_channels = [ channel - for channel in all_channels - if any( - re.fullmatch(channel_to_connect, channel["name"]) - for channel_to_connect in channels_to_connect + for channel in filtered_channels + if _channel_name_matches( + channel["name"], channels_to_include, include_regex_enabled ) ] - # validate that all channels in `channels_to_connect` are valid - # fail loudly in the case of an invalid channel so that the user - # knows that one of the channels they've specified is typo'd or private + # unlike includes, exclude names aren't validated — excluding a missing channel is harmless + if channels_to_exclude: + filtered_channels = [ + channel + for channel in filtered_channels + if not _channel_name_matches( + channel["name"], channels_to_exclude, exclude_regex_enabled + ) + ] + + return filtered_channels + + +def _channel_name_matches( + channel_name: str, patterns: list[str], regex_enabled: bool +) -> bool: + if regex_enabled: + return any(re.fullmatch(pattern, channel_name) for pattern in patterns) + return channel_name in patterns + + +def _validate_channels_exist( + all_channels: list[ChannelType], channels_to_include: list[str] +) -> None: + # fail loudly on an unknown channel so the user knows one of the + # channels they've specified is typo'd or private all_channel_names = {channel["name"] for channel in all_channels} - for channel in channels_to_connect: + for channel in channels_to_include: if channel not in all_channel_names: raise ValueError( f"Channel '{channel}' not found in workspace. " @@ -437,9 +462,15 @@ def filter_channels( f"{list(itertools.islice(all_channel_names, SlackConnector.MAX_CHANNELS_TO_LOG))}" ) - return [ - channel for channel in all_channels if channel["name"] in channels_to_connect - ] + +def _validate_channel_regexes(patterns: list[str] | None, label: str) -> None: + for pattern in patterns or []: + try: + re.compile(pattern) + except re.error as e: + raise ConnectorValidationError( + f"Invalid {label} regex '{pattern}': {e}" + ) from e def _channel_to_hierarchy_node( @@ -597,8 +628,10 @@ def _message_to_doc( def _get_all_doc_ids( client: WebClient, - channels: list[str] | None = None, - channel_name_regex_enabled: bool = False, + channels_to_include: list[str] | None = None, + include_regex_enabled: bool = False, + channels_to_exclude: list[str] | None = None, + exclude_regex_enabled: bool = False, msg_filter_func: Callable[ [MessageType], SlackMessageFilterReason | None ] = default_msg_filter, @@ -621,7 +654,11 @@ def _get_all_doc_ids( else: all_channels = get_channels(client) filtered_channels = filter_channels( - all_channels, channels, channel_name_regex_enabled + all_channels, + channels_to_include, + include_regex_enabled, + channels_to_exclude, + exclude_regex_enabled, ) user_cache: dict[str, BasicExpertInfo | None] = {} @@ -781,6 +818,11 @@ class SlackConnector( # if specified, will treat the specified channel strings as # regexes, and will only index channels that fully match the regexes channel_regex_enabled: bool = False, + # channels to skip; applied after the include filter above + exclude_channels: list[str] | None = None, + # if specified, will treat the excluded channel strings as + # regexes, and will skip channels that fully match the regexes + exclude_channel_regex_enabled: bool = False, # if True, messages from bots/apps will be indexed instead of filtered out include_bot_messages: bool = False, batch_size: int = INDEX_BATCH_SIZE, @@ -789,6 +831,8 @@ class SlackConnector( ) -> None: self.channels = channels self.channel_regex_enabled = channel_regex_enabled + self.exclude_channels = exclude_channels + self.exclude_channel_regex_enabled = exclude_channel_regex_enabled self.include_bot_messages = include_bot_messages self.msg_filter_func = ( _bot_inclusive_msg_filter if include_bot_messages else default_msg_filter @@ -1039,8 +1083,10 @@ class SlackConnector( return _get_all_doc_ids( client=self.client, - channels=self.channels, - channel_name_regex_enabled=self.channel_regex_enabled, + channels_to_include=self.channels, + include_regex_enabled=self.channel_regex_enabled, + channels_to_exclude=self.exclude_channels, + exclude_regex_enabled=self.exclude_channel_regex_enabled, msg_filter_func=self.msg_filter_func, callback=callback, workspace_url=self._workspace_url, @@ -1087,7 +1133,11 @@ class SlackConnector( else: raw_channels = get_channels(self.client) filtered_channels = filter_channels( - raw_channels, self.channels, self.channel_regex_enabled + raw_channels, + self.channels, + self.channel_regex_enabled, + self.exclude_channels, + self.exclude_channel_regex_enabled, ) logger.info( "Channels - initial checkpoint: all=%s post_filtering=%s", @@ -1364,10 +1414,18 @@ class SlackConnector( def validate_connector_settings(self) -> None: """ - 1. Verify the bot token is valid for the workspace (via auth_test). - 2. Ensure the bot has enough scope to list channels. - 3. Check that every channel specified in self.channels exists (only when regex is not enabled). + 1. Verify any channel include/exclude regexes compile. + 2. Verify the bot token is valid for the workspace (via auth_test). + 3. Ensure the bot has enough scope to list channels. + + Channel existence (for non-regex includes) is validated during indexing + via filter_channels, not here. """ + if self.channel_regex_enabled: + _validate_channel_regexes(self.channels, "channel") + if self.exclude_channel_regex_enabled: + _validate_channel_regexes(self.exclude_channels, "excluded channel") + if self.fast_client is None: raise ConnectorMissingCredentialError("Slack credentials not loaded.") diff --git a/backend/tests/unit/onyx/connectors/slack/test_slack_filter_channels.py b/backend/tests/unit/onyx/connectors/slack/test_slack_filter_channels.py new file mode 100644 index 0000000000..12770eb024 --- /dev/null +++ b/backend/tests/unit/onyx/connectors/slack/test_slack_filter_channels.py @@ -0,0 +1,136 @@ +"""Channel include/exclude filtering for the Slack connector: excludes apply +after includes, support exact-name and full-match regex modes, and never fail +on names absent from the workspace.""" + +from typing import Any +from typing import cast + +import pytest + +from onyx.connectors.exceptions import ConnectorValidationError +from onyx.connectors.slack.connector import _validate_channel_regexes +from onyx.connectors.slack.connector import filter_channels +from onyx.connectors.slack.models import ChannelType + + +def _channel(name: str) -> ChannelType: + base: dict[str, Any] = {"id": f"C-{name}", "name": name} + return cast(ChannelType, base) + + +CHANNELS = [ + _channel("general"), + _channel("support"), + _channel("infra-alerts"), + _channel("billing-alerts"), + _channel("deploy-notifications"), +] + + +def _names(channels: list[ChannelType]) -> list[str]: + return [channel["name"] for channel in channels] + + +def test_no_filters_returns_all() -> None: + assert filter_channels(CHANNELS, None, False) == CHANNELS + + +def test_exclude_regex_without_includes() -> None: + result = filter_channels( + CHANNELS, + None, + False, + channels_to_exclude=[".*-alerts", ".*-notifications"], + exclude_regex_enabled=True, + ) + assert _names(result) == ["general", "support"] + + +def test_exclude_exact_names() -> None: + result = filter_channels( + CHANNELS, + None, + False, + channels_to_exclude=["support"], + exclude_regex_enabled=False, + ) + assert _names(result) == [ + "general", + "infra-alerts", + "billing-alerts", + "deploy-notifications", + ] + + +def test_exclude_exact_requires_full_name() -> None: + result = filter_channels( + CHANNELS, + None, + False, + channels_to_exclude=["alerts"], + exclude_regex_enabled=False, + ) + assert result == CHANNELS + + +def test_exclude_applied_after_include_regex() -> None: + result = filter_channels( + CHANNELS, + ["infra-.*", "general"], + True, + channels_to_exclude=[".*-alerts"], + exclude_regex_enabled=True, + ) + assert _names(result) == ["general"] + + +def test_exclude_applied_after_include_exact() -> None: + result = filter_channels( + CHANNELS, + ["general", "support"], + False, + channels_to_exclude=["support"], + exclude_regex_enabled=False, + ) + assert _names(result) == ["general"] + + +def test_exclude_nonexistent_name_is_harmless() -> None: + result = filter_channels( + CHANNELS, + None, + False, + channels_to_exclude=["no-such-channel"], + exclude_regex_enabled=False, + ) + assert result == CHANNELS + + +def test_exclude_regex_is_full_match() -> None: + # "alerts" only partially matches "infra-alerts", so nothing is excluded + result = filter_channels( + CHANNELS, + None, + False, + channels_to_exclude=["alerts"], + exclude_regex_enabled=True, + ) + assert result == CHANNELS + + +def test_include_validation_still_raises_for_unknown_channel() -> None: + with pytest.raises(ValueError, match="not found in workspace"): + filter_channels(CHANNELS, ["typo-channel"], False) + + +def test_validate_channel_regexes_accepts_valid_patterns() -> None: + _validate_channel_regexes([".*-alerts$", "general"], "channel") + + +def test_validate_channel_regexes_rejects_invalid_pattern() -> None: + with pytest.raises(ConnectorValidationError, match="Invalid channel regex"): + _validate_channel_regexes(["[unclosed"], "channel") + + +def test_validate_channel_regexes_handles_none() -> None: + _validate_channel_regexes(None, "channel") diff --git a/web/src/components/admin/connectors/ConnectorTitle.tsx b/web/src/components/admin/connectors/ConnectorTitle.tsx index a758fa98f2..022e9e4f1e 100644 --- a/web/src/components/admin/connectors/ConnectorTitle.tsx +++ b/web/src/components/admin/connectors/ConnectorTitle.tsx @@ -85,6 +85,20 @@ export const ConnectorTitle = ({ if (typedConnector.connector_specific_config.channel_regex_enabled) { additionalMetadata.set("Channel Regex Enabled", "True"); } + if ( + typedConnector.connector_specific_config?.exclude_channels && + typedConnector.connector_specific_config.exclude_channels.length > 0 + ) { + additionalMetadata.set( + "Excluded Channels", + typedConnector.connector_specific_config.exclude_channels.join(", ") + ); + } + if ( + typedConnector.connector_specific_config.exclude_channel_regex_enabled + ) { + additionalMetadata.set("Exclude Channel Regex Enabled", "True"); + } if (typedConnector.connector_specific_config.include_bot_messages) { additionalMetadata.set("Include Bot Messages", "True"); } diff --git a/web/src/lib/connectors/connectors.tsx b/web/src/lib/connectors/connectors.tsx index 7c36400924..fc7e2ea620 100644 --- a/web/src/lib/connectors/connectors.tsx +++ b/web/src/lib/connectors/connectors.tsx @@ -1096,6 +1096,25 @@ export const connectorConfigs: Record< For example, specifying .*-support.* as a "channel" will cause the connector to include any channels with "-support" in the name.`, optional: true, }, + { + type: "list", + query: "Enter channels to exclude:", + label: "Channels to Exclude", + name: "exclude_channels", + description: `Specify 0 or more channels to exclude. Exclusions are applied after the "Channels" filter above, so a channel matched by both is excluded. If no channels are specified, nothing is excluded.`, + optional: true, + // Slack Channels can only be lowercase + transform: (values) => values.map((value) => value.toLowerCase()), + }, + { + type: "checkbox", + query: "Enable exclude channel regex?", + label: "Enable Exclude Channel Regex", + name: "exclude_channel_regex_enabled", + description: `If enabled, we will treat the "channels to exclude" specified above as regular expressions. A channel will be excluded if its name fully matches any of the specified regular expressions. +For example, specifying .*-alerts as a "channel to exclude" will cause the connector to skip any channels ending in "-alerts".`, + optional: true, + }, { type: "checkbox", query: "Include bot messages?", @@ -2055,6 +2074,8 @@ export interface SlackConfig { workspace: string; channels?: string[]; channel_regex_enabled?: boolean; + exclude_channels?: string[]; + exclude_channel_regex_enabled?: boolean; include_bot_messages?: boolean; }