feat(slack-connector): add channel exclude list with optional regex (#11996)

This commit is contained in:
Nikolas Garza authored and GitHub committed 2026-06-12 20:58:47 +00:00
1 parent 615ca3d8c4
commit fd3ac47925
4 files changed
+255 -26

No files matched your search

+84 -26
View File
@@ -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.")
@@ -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")
@@ -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");
}
+21
View File
@@ -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;
}