diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index 16165d5e322..0a9a2efddf8 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -128,21 +128,34 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]: logger.warning("Channel directory: failed to build %s: %s", platform.value, e) # Platforms that don't support direct channel enumeration get session-based - # discovery automatically. Skip infrastructure entries that aren't messaging - # platforms — everything else falls through to _build_from_sessions(). + # discovery automatically, but only for platforms connected in THIS gateway + # process. Historical session origins for disabled/decommissioned platforms + # must not be resurrected into the active send-target directory (stale + # targets make send_message route to platforms that can no longer deliver). _SKIP_SESSION_DISCOVERY = frozenset({"local", "api_server", "webhook"}) + adapter_platform_names = {getattr(p, "value", str(p)) for p in adapters} for plat in Platform: plat_name = plat.value - if plat_name in _SKIP_SESSION_DISCOVERY or plat_name in platforms: + if ( + plat_name in _SKIP_SESSION_DISCOVERY + or plat_name in platforms + or plat_name not in adapter_platform_names + ): continue platforms[plat_name] = _build_from_sessions(plat_name) # Include plugin-registered platforms (dynamic enum members aren't in - # Platform.__members__, so the loop above misses them). + # Platform.__members__, so the loop above misses them). Same + # connected-only rule: don't expose stale session targets for plugins + # that are not loaded. try: from gateway.platform_registry import platform_registry for entry in platform_registry.plugin_entries(): - if entry.name not in _SKIP_SESSION_DISCOVERY and entry.name not in platforms: + if ( + entry.name not in _SKIP_SESSION_DISCOVERY + and entry.name not in platforms + and entry.name in adapter_platform_names + ): platforms[entry.name] = _build_from_sessions(entry.name) except Exception: pass diff --git a/tests/gateway/test_channel_directory_connected_only.py b/tests/gateway/test_channel_directory_connected_only.py new file mode 100644 index 00000000000..45da8711bf4 --- /dev/null +++ b/tests/gateway/test_channel_directory_connected_only.py @@ -0,0 +1,46 @@ +"""Session-based channel discovery must not resurrect disconnected platforms. + +Surgical reapply of the directory portion of PR #25959: historical session +origins for platforms with no connected adapter must not become active +send_message targets.""" + +import asyncio +from unittest.mock import patch + +from gateway.channel_directory import build_channel_directory +from gateway.platforms.base import Platform + + +def test_does_not_resurrect_disconnected_platforms_from_session_history(tmp_path, monkeypatch): + cache_file = tmp_path / "channel_directory.json" + + calls = [] + + def fake_build_from_sessions(plat_name): + calls.append(plat_name) + return {"channels": [{"id": "1", "name": "old"}]} + + with patch("gateway.channel_directory._build_from_sessions", side_effect=fake_build_from_sessions), \ + patch("gateway.channel_directory.DIRECTORY_PATH", cache_file): + # Only telegram is connected; no discord/slack/whatsapp adapters. + directory = asyncio.run(build_channel_directory({Platform.TELEGRAM: object()})) + + plats = directory["platforms"] + assert "telegram" in plats + # Disconnected platforms must not appear via session discovery. + for stale in ("whatsapp", "signal", "matrix"): + assert stale not in plats, f"{stale} resurrected from session history" + assert set(calls) <= {"telegram"} + + +def test_connected_platform_still_uses_session_discovery(tmp_path): + cache_file = tmp_path / "channel_directory.json" + + with patch( + "gateway.channel_directory._build_from_sessions", + return_value={"channels": []}, + ) as mock_sessions, patch("gateway.channel_directory.DIRECTORY_PATH", cache_file): + directory = asyncio.run(build_channel_directory({Platform.TELEGRAM: object()})) + + assert "telegram" in directory["platforms"] + mock_sessions.assert_any_call("telegram")