mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-23 03:42:59 -05:00
feat(slack-connector): add channel exclude list with optional regex (#11996)
This commit is contained in:
1 parent
615ca3d8c4
commit
fd3ac47925
4 files changed
+255
-26
No files matched your search
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user