fix(gateway): only session-discover channel targets for connected platforms (#60574)

Session-based channel discovery resurrected historical origins for
platforms with no connected adapter, exposing stale send_message
targets that can no longer deliver. Gate both the enum loop and the
plugin-registry loop on the live adapter set.

Surgical reapply of the channel-directory portion of PR #25959 (branch
was 6.5k commits stale; the text-batching delay changes bundled there
were dropped - separate concern, defaults have since been retuned on
main).

Co-authored-by: Marco-Olivier Lavoie <marcolivier@gmail.com>
This commit is contained in:
Teknium 2026-07-07 17:04:32 -07:00 committed by GitHub
parent db9e3e4ef9
commit 6ca3d701fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 64 additions and 5 deletions

View file

@ -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

View file

@ -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")