From a2c96a6d20c011d190f0f48935b2952865a0ebdb Mon Sep 17 00:00:00 2001 From: Juniper Bevensee Date: Thu, 9 Jul 2026 13:57:50 +1000 Subject: [PATCH] feat(slack): auto-approve channels the bot is added to (parity with Signal reconcile) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #68. Slack had no equivalent of Signal's group reconcile: channels had to be listed in SLACK_ALLOWED_CHANNELS manually, so adding the bot to a new channel did nothing until an operator edited the env var. Mirror the Signal pattern with two additive, self-contained entry points: - member_joined_channel event handler — when the joining member is our bot user, auto-approve the channel (trusts the *action* of being added, the way Signal's invite handler trusts the inviter). - _reconcile_channels() on connect — list the bot's member channels via users.conversations and approve them, recovering the add-while-offline gap (analogous to Signal's _reconcile_groups add-at-creation recovery). Approvals are persisted to slack_approved_channels.json under HERMES_HOME so they survive restarts, and unioned into the effective allowlist only when a static whitelist is active — with no whitelist the bot already responds everywhere, so approving would wrongly turn an unrestricted install into a restricted one (matches Signal's early return on the "*" wildcard). Gated by SLACK_CHANNEL_JOIN_POLICY (auto|disabled, default auto). All paths are best-effort and never break connect. 26 new tests cover policy parsing, the allowlist union, persistence round-trip, the join handler, and reconcile (pagination + failure isolation). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BZiFLur67KjEViZb3oQ2Z3 --- plugins/platforms/slack/adapter.py | 314 +++++++++++++++- .../test_slack_channel_auto_approve.py | 340 ++++++++++++++++++ 2 files changed, 647 insertions(+), 7 deletions(-) create mode 100644 tests/gateway/test_slack_channel_auto_approve.py diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index 9fe6f368f..b3544662b 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -62,6 +62,12 @@ logger = logging.getLogger(__name__) +# Persistence file for runtime-approved channels (relative to HERMES_HOME). +# Mirrors Signal's ``signal_approved_groups.json``: when the bot is added to a +# channel, that channel is auto-approved into the runtime allowlist and written +# here so the approval survives restarts. +_APPROVED_CHANNELS_FILE = "slack_approved_channels.json" + # ContextVar carrying the user_id of the slash-command invoker. # Set in _handle_slash_command, read in send() to match the correct # stashed response_url when multiple users issue commands on the same @@ -487,6 +493,16 @@ def __init__(self, config: PlatformConfig): self._socket_reconnect_lock = asyncio.Lock() self._socket_watchdog_interval_s = 15.0 + # Runtime-approved channels — auto-approved when the bot is added to a + # channel (member_joined_channel) or discovered as a member at connect + # time (_reconcile_channels). Mirrors Signal's group reconcile: when a + # SLACK_ALLOWED_CHANNELS whitelist is active, these are unioned into the + # effective allowlist so an admin adding the bot to a channel doesn't + # need a manual env-var edit. Loaded from a persisted JSON file so the + # approvals survive restarts. + self._runtime_approved_channels: set = set() + self._load_approved_channels() + def _start_socket_mode_handler(self) -> None: """Start the Slack Socket Mode background task.""" if not self._app or not self._app_token: @@ -1126,6 +1142,13 @@ async def handle_reaction_added(event, say): async def handle_reaction_removed(event, say): pass + # Auto-approve channels the bot is added to (issue #68 — parity + # with Signal's group reconcile). member_joined_channel fires for + # every join; the handler no-ops unless the joiner is our bot. + @self._app.event("member_joined_channel") + async def handle_member_joined_channel(event, say): + await self._handle_member_joined_channel(event) + @self._app.event("assistant_thread_started") async def handle_assistant_thread_started(event, say): await self._handle_assistant_thread_lifecycle_event(event) @@ -1261,6 +1284,17 @@ async def _wrapped(ack, body, action): "[Slack] Socket Mode connected (%d workspace(s))", len(self._team_clients), ) + + # Auto-approve channels the bot is already a member of (issue #68). + # Best-effort and non-fatal — a failure here must never fail connect. + try: + await self._reconcile_channels() + except Exception: # pragma: no cover - defensive; reconcile self-guards + logger.debug( + "[Slack] Channel reconciliation raised (non-fatal)", + exc_info=True, + ) + return True except Exception as e: # pragma: no cover - defensive logging @@ -4217,14 +4251,13 @@ def _slack_free_response_channels(self) -> set: return {part.strip() for part in s.split(",") if part.strip()} return set() - def _slack_allowed_channels(self) -> set: - """Return the whitelist of channel IDs the bot will respond in. + def _slack_configured_channels(self) -> set: + """Parse the *statically configured* channel whitelist. - When non-empty, messages from channels NOT in this set are ignored — - even if the bot is @mentioned. DMs are never filtered. - Empty set means no restriction (fully backward compatible). - A "*" entry is treated as a wildcard by the caller (no restriction), - matching the Signal group allowlist behavior. + Reads ``slack.allowed_channels`` (list or CSV string) or the + ``SLACK_ALLOWED_CHANNELS`` env var. This is the operator-authored set + only — runtime auto-approvals are layered on top by + :meth:`_slack_allowed_channels`. """ raw = self.config.extra.get("allowed_channels") if raw is None: @@ -4235,6 +4268,273 @@ def _slack_allowed_channels(self) -> set: return {part.strip() for part in raw.split(",") if part.strip()} return set() + def _slack_allowed_channels(self) -> set: + """Return the effective whitelist of channel IDs the bot responds in. + + When non-empty, messages from channels NOT in this set are ignored — + even if the bot is @mentioned. DMs are never filtered. + Empty set means no restriction (fully backward compatible). + A "*" entry is treated as a wildcard by the caller (no restriction), + matching the Signal group allowlist behavior. + + Runtime-approved channels (auto-approved when the bot is added to a + channel — see :meth:`_reconcile_channels` and the + ``member_joined_channel`` handler) are unioned in **only when a static + whitelist is active**. With no static whitelist the bot already + responds everywhere, so approvals are irrelevant and would only turn an + unrestricted install into a restricted one — hence they are ignored. + """ + configured = self._slack_configured_channels() + # No static whitelist → unrestricted; runtime approvals don't apply. + # "*" wildcard → unrestricted; nothing to union. + if not configured or "*" in configured: + return configured + # getattr keeps object.__new__-constructed test adapters working. + approved = getattr(self, "_runtime_approved_channels", None) or set() + if approved: + return configured | approved + return configured + + # ------------------------------------------------------------------ + # Channel auto-approval (parity with Signal group reconcile — issue #68) + # ------------------------------------------------------------------ + + def _channel_join_policy(self) -> str: + """Return the auto-approval policy for channels the bot is added to. + + * ``"auto"`` (default) — auto-approve channels the bot is a member of. + A Slack workspace is admin-managed, so being added to a channel is + already an admin-gated action; the bot trusts it the way Signal's + invite handler trusts the inviter. + * ``"disabled"`` — never auto-approve; channels must be listed in + ``SLACK_ALLOWED_CHANNELS`` manually (pre-#68 behavior). + + Reads ``slack.channel_join_policy`` or ``SLACK_CHANNEL_JOIN_POLICY``. + """ + raw = None + if getattr(self, "config", None) is not None and self.config.extra: + raw = self.config.extra.get("channel_join_policy") + if raw is None: + raw = os.getenv("SLACK_CHANNEL_JOIN_POLICY", "auto") + value = str(raw).strip().lower() + return value if value in ("auto", "disabled") else "auto" + + def _channel_approval_active(self) -> bool: + """Whether runtime channel approvals are meaningful right now. + + Only when a static whitelist is configured (non-empty, no ``*``) does + auto-approval matter — otherwise the bot already responds everywhere, + so approving/persisting a channel is pointless churn (mirrors Signal + reconcile's early return when ``*`` is in the group allowlist). + """ + if self._channel_join_policy() == "disabled": + return False + configured = self._slack_configured_channels() + return bool(configured) and "*" not in configured + + def _load_approved_channels(self) -> None: + """Load runtime-approved channels persisted from previous sessions. + + Corrupted or missing files are non-fatal — we log and continue with an + empty set. Mirrors the Signal persisted-group loader. + """ + try: + from hermes_constants import get_hermes_home + + path = get_hermes_home() / _APPROVED_CHANNELS_FILE + if not path.exists(): + return + data = json.loads(path.read_text()) + if isinstance(data, dict): + loaded = {str(k) for k in data.keys() if str(k).strip()} + if loaded: + self._runtime_approved_channels.update(loaded) + logger.info( + "[Slack] Loaded %d persisted approved channel(s) from %s", + len(loaded), + _APPROVED_CHANNELS_FILE, + ) + except Exception: + logger.warning( + "[Slack] Failed to load persisted channel allowlist from %s — " + "continuing with configured channels only", + _APPROVED_CHANNELS_FILE, + exc_info=True, + ) + + def _persist_approved_channel( + self, + channel_id: str, + team_id: str = "", + added_by: str = "", + ) -> None: + """Write a newly-approved channel to the persistent JSON allowlist. + + Read-modify-write so multiple channels accumulate correctly. Uses a + tmp-file + ``os.replace`` for atomicity. Failures are logged as + warnings and never propagated — this is a best-effort side effect of a + successful approval. Mirrors Signal's ``_persist_approved_group``. + """ + try: + from datetime import datetime, timezone + from hermes_constants import get_hermes_home + + path = get_hermes_home() / _APPROVED_CHANNELS_FILE + existing: dict = {} + if path.exists(): + try: + existing = json.loads(path.read_text()) + if not isinstance(existing, dict): + existing = {} + except Exception: + existing = {} + existing[channel_id] = { + "team_id": team_id or "", + "added_by": added_by or "", + "approved_at": datetime.now(timezone.utc).isoformat(), + } + import tempfile + + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp( + dir=str(path.parent), + prefix=f".{path.stem}_", + suffix=".tmp", + ) + try: + with os.fdopen(fd, "w") as fh: + json.dump(existing, fh, indent=2) + os.replace(tmp, str(path)) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + except Exception: + logger.warning( + "[Slack] Failed to persist approved channel %s to %s (non-fatal)", + channel_id, + _APPROVED_CHANNELS_FILE, + exc_info=True, + ) + + def _approve_channel( + self, + channel_id: str, + team_id: str = "", + added_by: str = "", + reason: str = "", + ) -> bool: + """Add a channel to the runtime allowlist and persist it. + + Returns True if this was a newly-approved channel, False if it was + already approved/configured or approval is not currently active. Safe + to call repeatedly (idempotent). + """ + if not channel_id: + return False + if not self._channel_approval_active(): + return False + if channel_id in self._slack_configured_channels(): + return False + if channel_id in self._runtime_approved_channels: + return False + self._runtime_approved_channels.add(channel_id) + logger.info( + "[Slack] Auto-approving channel %s%s", + channel_id, + f" ({reason})" if reason else "", + ) + self._persist_approved_channel(channel_id, team_id, added_by) + return True + + async def _reconcile_channels(self) -> None: + """Auto-approve channels the bot is already a member of. + + Closes the add-at-creation gap: when an admin adds the bot to a channel + while the gateway is offline (or the ``member_joined_channel`` event is + missed), no event fires and the channel stays outside the whitelist. On + connect we list the bot's member channels via ``users.conversations`` + and approve them, reusing the same persistence so they survive + restarts. Best-effort — failures never break startup. Mirrors Signal's + :meth:`_reconcile_groups`. + """ + if not self._channel_approval_active(): + return + approved = 0 + for team_id, client in list(getattr(self, "_team_clients", {}).items()): + cursor: Optional[str] = None + pages = 0 + try: + while True: + pages += 1 + resp = await client.users_conversations( + types="public_channel,private_channel", + exclude_archived=True, + limit=200, + **({"cursor": cursor} if cursor else {}), + ) + channels = (resp.get("channels") or []) if isinstance(resp, dict) else [] + for ch in channels: + if not isinstance(ch, dict): + continue + cid = ch.get("id") + if not cid: + continue + if self._approve_channel( + cid, team_id=team_id, reason="member at connect" + ): + approved += 1 + cursor = None + meta = resp.get("response_metadata") if isinstance(resp, dict) else None + if isinstance(meta, dict): + cursor = (meta.get("next_cursor") or "").strip() or None + # Cap pagination so a pathological workspace can't stall connect. + if not cursor or pages >= 20: + break + except Exception: + logger.debug( + "[Slack] Channel reconciliation skipped for team %s " + "(users.conversations failed)", + team_id, + exc_info=True, + ) + if approved: + logger.info( + "[Slack] Channel reconciliation auto-approved %d channel(s)", + approved, + ) + + async def _handle_member_joined_channel(self, event: dict) -> None: + """Auto-approve a channel when the bot itself is added to it. + + The ``member_joined_channel`` event is an *action* — an admin added a + member — so, like Signal's invite handler, we trust it and approve when + the joining member is our own bot user. Best-effort; never raises into + the Bolt dispatcher. + """ + try: + if not isinstance(event, dict): + return + channel_id = event.get("channel") + joiner = event.get("user") + team_id = event.get("team") or self._channel_team.get(channel_id, "") + bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id) + if not channel_id or not joiner or joiner != bot_uid: + return + self._approve_channel( + channel_id, + team_id=team_id, + added_by=event.get("inviter") or "", + reason="bot added to channel", + ) + except Exception: + logger.warning( + "[Slack] member_joined_channel handling failed (non-fatal)", + exc_info=True, + ) + def _slack_mention_patterns(self) -> List["re.Pattern"]: """Compile optional regex wake-word patterns for channel triggers. diff --git a/tests/gateway/test_slack_channel_auto_approve.py b/tests/gateway/test_slack_channel_auto_approve.py new file mode 100644 index 000000000..f5d9f8905 --- /dev/null +++ b/tests/gateway/test_slack_channel_auto_approve.py @@ -0,0 +1,340 @@ +""" +Tests for Slack channel auto-approval (issue #68 — parity with Signal group +reconcile). + +When an admin adds the bot to a channel, that channel should be auto-approved +into the runtime allowlist — the same way Signal auto-approves a group the bot +is added to — so it works without a manual ``SLACK_ALLOWED_CHANNELS`` edit. + +Two entry points, mirroring Signal: +* ``member_joined_channel`` — the bot is added live (analogous to the Signal + invite handler, which trusts the *action* of being added). +* ``_reconcile_channels`` — on connect, list the bot's member channels via + ``users.conversations`` and approve them (analogous to Signal's + ``_reconcile_groups`` add-at-creation recovery). + +Approvals only matter when a static whitelist is active — with no whitelist the +bot already responds everywhere, so approving is pointless churn (this mirrors +Signal reconcile's early return when ``*`` is in the group allowlist). +""" + +import json +import sys +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import Platform, PlatformConfig + + +# --------------------------------------------------------------------------- +# Mock slack-bolt if not installed (same pattern as test_slack_mention.py) +# --------------------------------------------------------------------------- + +def _ensure_slack_mock(): + if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"): + return + + slack_bolt = MagicMock() + slack_bolt.async_app.AsyncApp = MagicMock + slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock + + slack_sdk = MagicMock() + slack_sdk.web.async_client.AsyncWebClient = MagicMock + + for name, mod in [ + ("slack_bolt", slack_bolt), + ("slack_bolt.async_app", slack_bolt.async_app), + ("slack_bolt.adapter", slack_bolt.adapter), + ("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode), + ("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler), + ("slack_sdk", slack_sdk), + ("slack_sdk.web", slack_sdk.web), + ("slack_sdk.web.async_client", slack_sdk.web.async_client), + ]: + sys.modules.setdefault(name, mod) + + +_ensure_slack_mock() + +import plugins.platforms.slack.adapter as _slack_mod # noqa: E402 +_slack_mod.SLACK_AVAILABLE = True + +from plugins.platforms.slack.adapter import SlackAdapter, _APPROVED_CHANNELS_FILE # noqa: E402 + + +BOT_USER_ID = "U_BOT_123" +OTHER_USER_ID = "U_HUMAN_456" +CHANNEL_ID = "C0AQWDLHY9M" +OTHER_CHANNEL_ID = "C9999999999" +TEAM_ID = "T_TEAM_1" + + +def _make_adapter(allowed_channels=None, channel_join_policy=None): + """Build a Slack adapter with just the state the auto-approve code needs. + + Uses ``object.__new__`` to avoid the full connect machinery, then wires the + attributes the methods under test read (mirrors ``_make_adapter`` in + test_slack_mention.py, plus the auto-approve state ``__init__`` sets up). + """ + extra = {} + if allowed_channels is not None: + extra["allowed_channels"] = allowed_channels + if channel_join_policy is not None: + extra["channel_join_policy"] = channel_join_policy + + adapter = object.__new__(SlackAdapter) + adapter.platform = Platform.SLACK + adapter.config = PlatformConfig(enabled=True, extra=extra) + adapter._bot_user_id = BOT_USER_ID + adapter._team_bot_user_ids = {TEAM_ID: BOT_USER_ID} + adapter._channel_team = {} + adapter._team_clients = {} + adapter._runtime_approved_channels = set() + return adapter + + +@pytest.fixture(autouse=True) +def _isolated_home(monkeypatch, tmp_path): + """Point HERMES_HOME at a tmp dir so persistence never touches real state.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("SLACK_ALLOWED_CHANNELS", raising=False) + monkeypatch.delenv("SLACK_CHANNEL_JOIN_POLICY", raising=False) + return tmp_path + + +# --------------------------------------------------------------------------- +# _channel_join_policy +# --------------------------------------------------------------------------- + +def test_join_policy_defaults_to_auto(): + assert _make_adapter()._channel_join_policy() == "auto" + + +def test_join_policy_disabled_via_config(): + adapter = _make_adapter(channel_join_policy="disabled") + assert adapter._channel_join_policy() == "disabled" + + +def test_join_policy_env_fallback(monkeypatch): + monkeypatch.setenv("SLACK_CHANNEL_JOIN_POLICY", "disabled") + assert _make_adapter()._channel_join_policy() == "disabled" + + +def test_join_policy_unknown_value_defaults_to_auto(): + assert _make_adapter(channel_join_policy="banana")._channel_join_policy() == "auto" + + +# --------------------------------------------------------------------------- +# _channel_approval_active +# --------------------------------------------------------------------------- + +def test_approval_inactive_without_whitelist(): + # No whitelist → bot responds everywhere → approval is a no-op. + assert _make_adapter()._channel_approval_active() is False + + +def test_approval_inactive_with_wildcard(): + assert _make_adapter(allowed_channels="*")._channel_approval_active() is False + + +def test_approval_active_with_whitelist(): + assert _make_adapter(allowed_channels=[OTHER_CHANNEL_ID])._channel_approval_active() is True + + +def test_approval_inactive_when_policy_disabled(): + adapter = _make_adapter(allowed_channels=[OTHER_CHANNEL_ID], channel_join_policy="disabled") + assert adapter._channel_approval_active() is False + + +# --------------------------------------------------------------------------- +# _slack_allowed_channels union behavior +# --------------------------------------------------------------------------- + +def test_allowed_channels_unions_runtime_approvals(): + adapter = _make_adapter(allowed_channels=[OTHER_CHANNEL_ID]) + adapter._runtime_approved_channels = {CHANNEL_ID} + result = adapter._slack_allowed_channels() + assert result == {OTHER_CHANNEL_ID, CHANNEL_ID} + + +def test_empty_whitelist_ignores_runtime_approvals(): + # Critical: an unrestricted install must STAY unrestricted even if the + # persisted-approvals set somehow has entries. + adapter = _make_adapter() + adapter._runtime_approved_channels = {CHANNEL_ID} + assert adapter._slack_allowed_channels() == set() + + +def test_wildcard_whitelist_ignores_runtime_approvals(): + adapter = _make_adapter(allowed_channels="*") + adapter._runtime_approved_channels = {CHANNEL_ID} + assert adapter._slack_allowed_channels() == {"*"} + + +# --------------------------------------------------------------------------- +# _approve_channel +# --------------------------------------------------------------------------- + +def test_approve_channel_adds_and_persists(tmp_path): + adapter = _make_adapter(allowed_channels=[OTHER_CHANNEL_ID]) + assert adapter._approve_channel(CHANNEL_ID, team_id=TEAM_ID, added_by=OTHER_USER_ID) is True + assert CHANNEL_ID in adapter._runtime_approved_channels + # Persisted to disk. + data = json.loads((tmp_path / _APPROVED_CHANNELS_FILE).read_text()) + assert CHANNEL_ID in data + assert data[CHANNEL_ID]["team_id"] == TEAM_ID + assert data[CHANNEL_ID]["added_by"] == OTHER_USER_ID + + +def test_approve_channel_idempotent(): + adapter = _make_adapter(allowed_channels=[OTHER_CHANNEL_ID]) + assert adapter._approve_channel(CHANNEL_ID) is True + assert adapter._approve_channel(CHANNEL_ID) is False # already approved + + +def test_approve_channel_noop_when_inactive(tmp_path): + # No whitelist → not active → do not approve or persist. + adapter = _make_adapter() + assert adapter._approve_channel(CHANNEL_ID) is False + assert CHANNEL_ID not in adapter._runtime_approved_channels + assert not (tmp_path / _APPROVED_CHANNELS_FILE).exists() + + +def test_approve_channel_skips_already_configured(): + adapter = _make_adapter(allowed_channels=[CHANNEL_ID]) + # Already statically configured → nothing to add. + assert adapter._approve_channel(CHANNEL_ID) is False + + +# --------------------------------------------------------------------------- +# Persistence round-trip / load +# --------------------------------------------------------------------------- + +def test_load_approved_channels_round_trip(tmp_path): + seed = _make_adapter(allowed_channels=[OTHER_CHANNEL_ID]) + seed._approve_channel(CHANNEL_ID, team_id=TEAM_ID) + + # New adapter that actually runs the loader. + fresh = object.__new__(SlackAdapter) + fresh._runtime_approved_channels = set() + fresh._load_approved_channels() + assert CHANNEL_ID in fresh._runtime_approved_channels + + +def test_load_corrupt_file_is_non_fatal(tmp_path): + (tmp_path / _APPROVED_CHANNELS_FILE).write_text("{ not json") + fresh = object.__new__(SlackAdapter) + fresh._runtime_approved_channels = set() + fresh._load_approved_channels() # must not raise + assert fresh._runtime_approved_channels == set() + + +# --------------------------------------------------------------------------- +# member_joined_channel handler +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_member_joined_approves_when_bot_added(): + adapter = _make_adapter(allowed_channels=[OTHER_CHANNEL_ID]) + await adapter._handle_member_joined_channel( + {"channel": CHANNEL_ID, "user": BOT_USER_ID, "team": TEAM_ID, "inviter": OTHER_USER_ID} + ) + assert CHANNEL_ID in adapter._runtime_approved_channels + + +@pytest.mark.asyncio +async def test_member_joined_ignores_other_users(): + adapter = _make_adapter(allowed_channels=[OTHER_CHANNEL_ID]) + await adapter._handle_member_joined_channel( + {"channel": CHANNEL_ID, "user": OTHER_USER_ID, "team": TEAM_ID} + ) + assert CHANNEL_ID not in adapter._runtime_approved_channels + + +@pytest.mark.asyncio +async def test_member_joined_noop_without_whitelist(): + adapter = _make_adapter() # no whitelist → nothing to approve + await adapter._handle_member_joined_channel( + {"channel": CHANNEL_ID, "user": BOT_USER_ID, "team": TEAM_ID} + ) + assert CHANNEL_ID not in adapter._runtime_approved_channels + + +@pytest.mark.asyncio +async def test_member_joined_malformed_event_non_fatal(): + adapter = _make_adapter(allowed_channels=[OTHER_CHANNEL_ID]) + await adapter._handle_member_joined_channel("not-a-dict") # must not raise + + +# --------------------------------------------------------------------------- +# _reconcile_channels +# --------------------------------------------------------------------------- + +def _client_returning(channels, next_cursor=""): + client = MagicMock() + client.users_conversations = AsyncMock( + return_value={ + "channels": channels, + "response_metadata": {"next_cursor": next_cursor}, + } + ) + return client + + +@pytest.mark.asyncio +async def test_reconcile_approves_member_channels(): + adapter = _make_adapter(allowed_channels=[OTHER_CHANNEL_ID]) + adapter._team_clients = { + TEAM_ID: _client_returning([{"id": CHANNEL_ID}, {"id": "C_SECOND"}]) + } + await adapter._reconcile_channels() + assert CHANNEL_ID in adapter._runtime_approved_channels + assert "C_SECOND" in adapter._runtime_approved_channels + + +@pytest.mark.asyncio +async def test_reconcile_noop_without_whitelist(): + adapter = _make_adapter() # unrestricted → skip entirely + client = _client_returning([{"id": CHANNEL_ID}]) + adapter._team_clients = {TEAM_ID: client} + await adapter._reconcile_channels() + assert adapter._runtime_approved_channels == set() + client.users_conversations.assert_not_called() + + +@pytest.mark.asyncio +async def test_reconcile_noop_when_disabled(): + adapter = _make_adapter(allowed_channels=[OTHER_CHANNEL_ID], channel_join_policy="disabled") + client = _client_returning([{"id": CHANNEL_ID}]) + adapter._team_clients = {TEAM_ID: client} + await adapter._reconcile_channels() + assert adapter._runtime_approved_channels == set() + client.users_conversations.assert_not_called() + + +@pytest.mark.asyncio +async def test_reconcile_api_failure_non_fatal(): + adapter = _make_adapter(allowed_channels=[OTHER_CHANNEL_ID]) + client = MagicMock() + client.users_conversations = AsyncMock(side_effect=RuntimeError("slack down")) + adapter._team_clients = {TEAM_ID: client} + await adapter._reconcile_channels() # must not raise + assert adapter._runtime_approved_channels == set() + + +@pytest.mark.asyncio +async def test_reconcile_paginates(): + adapter = _make_adapter(allowed_channels=[OTHER_CHANNEL_ID]) + client = MagicMock() + client.users_conversations = AsyncMock( + side_effect=[ + {"channels": [{"id": CHANNEL_ID}], "response_metadata": {"next_cursor": "next"}}, + {"channels": [{"id": "C_PAGE2"}], "response_metadata": {"next_cursor": ""}}, + ] + ) + adapter._team_clients = {TEAM_ID: client} + await adapter._reconcile_channels() + assert CHANNEL_ID in adapter._runtime_approved_channels + assert "C_PAGE2" in adapter._runtime_approved_channels + assert client.users_conversations.await_count == 2