feat(slack): bridge slack.ignored_channels through the YAML→env config path

Follow-up to the #51899 pick, folding in the config-bridge half of the
competing PR #46925 (@bhanusharma, earliest submitter for the
ignored-channel gate):

- _apply_yaml_config: translate config.yaml slack.ignored_channels into
  SLACK_IGNORED_CHANNELS (list or CSV), env-var-wins like every other
  bridged Slack key.
- SlackAdapter._slack_ignored_channels / gateway.run's
  _slack_ignored_channels_from_gateway_config: fall back to the
  SLACK_IGNORED_CHANNELS env var when PlatformConfig.extra carries no
  value, so top-level slack: blocks (which flow through the env bridge,
  not extra) are honored at both the adapter and runner gates.
- conftest: force-clear SLACK_ALLOWED_CHANNELS / SLACK_IGNORED_CHANNELS /
  SLACK_DISABLE_DMS between tests (config-loader side-effect leak class).
- Tests: env-bridge translation + precedence in test_config.py, env
  fallback + extra-wins in test_slack_runner_ignored_channels.py.

Credit: #46925 by @bhanusharma proposed the same gate with the YAML→env
bridge; #51899 (picked as the base) carries the wider outbound/runner
coverage. Closes #46925 as consolidated here with first-submitter credit.
This commit is contained in:
Teknium 2026-07-23 08:08:39 -07:00
parent 0a5d8a16fc
commit da131aef3a
5 changed files with 72 additions and 1 deletions

View file

@ -966,6 +966,11 @@ def _slack_ignored_channels_from_gateway_config(config: Any) -> set[str]:
raw = None
if platform_cfg is not None:
raw = getattr(platform_cfg, "extra", {}).get("ignored_channels")
if raw is None:
# Top-level ``slack.ignored_channels`` config flows through the
# plugin's YAML→env bridge (SLACK_IGNORED_CHANNELS) rather than
# PlatformConfig.extra — honor it here too (#46925).
raw = os.getenv("SLACK_IGNORED_CHANNELS") or None
return _csv_or_list_to_set(raw)

View file

@ -8915,6 +8915,12 @@ def _apply_yaml_config(yaml_cfg: dict, slack_cfg: dict) -> dict | None:
if isinstance(ac, list):
ac = ",".join(str(v) for v in ac)
os.environ["SLACK_ALLOWED_CHANNELS"] = str(ac)
# ignored_channels: blacklist channels where Slack must never respond.
ic = slack_cfg.get("ignored_channels")
if ic is not None and not os.getenv("SLACK_IGNORED_CHANNELS"):
if isinstance(ic, list):
ic = ",".join(str(v) for v in ic)
os.environ["SLACK_IGNORED_CHANNELS"] = str(ic)
return None # all settings flow through env; nothing to merge into extras
@ -8952,7 +8958,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, disable_dms, allowed_channels) into SLACK_* env vars that
# reactions, disable_dms, allowed_channels, ignored_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,

View file

@ -321,6 +321,9 @@ _HERMES_BEHAVIORAL_VARS = frozenset({
"SLACK_IGNORE_OTHER_USER_MENTIONS",
"SLACK_REQUIRE_MENTION_CHANNELS",
"SLACK_FREE_RESPONSE_CHANNELS",
"SLACK_ALLOWED_CHANNELS",
"SLACK_IGNORED_CHANNELS",
"SLACK_DISABLE_DMS",
"SLACK_ALLOW_BOTS",
"SLACK_REACTIONS",
"DISCORD_REQUIRE_MENTION",

View file

@ -592,6 +592,42 @@ class TestLoadGatewayConfig:
assert os.getenv("SLACK_DISABLE_DMS") == "true"
def test_slack_ignored_channels_config_sets_env_bridge(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"slack:\n"
" ignored_channels:\n"
" - C0123456789\n"
" - C0987654321\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("SLACK_IGNORED_CHANNELS", raising=False)
load_gateway_config()
assert os.getenv("SLACK_IGNORED_CHANNELS") == "C0123456789,C0987654321"
def test_slack_ignored_channels_env_takes_precedence(self, tmp_path, monkeypatch):
"""An explicit SLACK_IGNORED_CHANNELS env var must not be overwritten
by the config.yaml bridge."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"slack:\n"
" ignored_channels: C_FROM_YAML\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("SLACK_IGNORED_CHANNELS", "C_FROM_ENV")
load_gateway_config()
assert os.getenv("SLACK_IGNORED_CHANNELS") == "C_FROM_ENV"
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

View file

@ -114,3 +114,23 @@ async def test_platform_notice_suppressed_for_slack_ignored_channel():
adapter.send.assert_not_called()
adapter.send_private_notice.assert_not_called()
def test_slack_ignored_channels_env_bridge_fallback(monkeypatch):
"""SLACK_IGNORED_CHANNELS (set by the plugin's YAML→env bridge) is
honored when PlatformConfig.extra carries no ignored_channels (#46925)."""
monkeypatch.setenv("SLACK_IGNORED_CHANNELS", "C_ENV1, C_ENV2")
config = _config_with_slack_extra({})
assert _slack_ignored_channels_from_gateway_config(config) == {"C_ENV1", "C_ENV2"}
assert _is_slack_ignored_channel(config, "C_ENV1")
assert not _is_slack_ignored_channel(config, "C_OTHER")
def test_slack_ignored_channels_extra_wins_over_env(monkeypatch):
"""Explicit PlatformConfig.extra config takes precedence over the env
bridge fallback."""
monkeypatch.setenv("SLACK_IGNORED_CHANNELS", "C_ENV")
config = _config_with_slack_extra({"ignored_channels": ["C_CFG"]})
assert _slack_ignored_channels_from_gateway_config(config) == {"C_CFG"}