diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index 703cea0470..10cec8775d 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -780,9 +780,31 @@ class RelayAdapter(BasePlatformAdapter): channel_id = str(payload.get("channel_id") or "") guild_id = payload.get("guild_id") # real Discord interaction wire field source = SessionSource( - platform=Platform.RELAY, + # The LOGICAL platform, not Platform.RELAY. This lane parses a + # Discord interaction wire payload, so the underlying platform is + # known statically — and it must be stamped for three consumers: + # 1. Session keys: the connector binds the interaction's + # follow-up capability under buildSessionKey with + # platform="discord" (interactionSessionSource); the relay + # TEXT lane (ws_transport._event_from_wire) also maps to the + # logical platform. RELAY here forked interaction sessions + # away from both. + # 2. /sethome: with platform=RELAY the handler filed the home + # channel under platforms.relay.home_channel (invisible to + # cron delivery, which looks up the logical platform) and + # mirrored it into the dead RELAY_HOME_CHANNEL env var. + # 3. Egress: _capture_scope records _platform_by_chat from this + # value and deliberately skips the generic "relay". + platform=Platform.DISCORD, chat_id=channel_id, - chat_type="channel" if guild_id else "dm", + # "group", not "channel": the session key embeds chat_type, and + # BOTH the connector's capability binding (interactionSessionSource + # → buildSessionKey, chat_type "group") and the native Discord + # adapter's channel events key guild channels as "group". A + # "channel" slot here forked the interaction session from the + # chat's message session AND from the vault key the connector + # bound the follow-up capability under. + chat_type="group" if guild_id else "dm", user_id=str(user.get("id")) if isinstance(user, dict) and user.get("id") else None, @@ -793,6 +815,15 @@ class RelayAdapter(BasePlatformAdapter): if guild_id else None, # Discord guild → generic scope slot message_id=str(payload.get("id")) if payload.get("id") else None, + # Same upstream-trust marker the relay text lane stamps + # (ws_transport._event_from_wire): this interaction arrived over + # the per-instance-authenticated relay WS after the connector + # verified Discord's edge signature and resolved the tenant. + # Without it, authz treated the event as unauthenticated relay + # traffic — and /sethome's via_relay guard never engaged, which is + # how platform=RELAY home channels slipped through in the first + # place. Set locally, never read off the wire. + delivered_via_upstream_relay=True, ) event = MessageEvent(text=text, message_type=message_type, source=source) if itype == 3: diff --git a/tests/gateway/relay/test_relay_passthrough.py b/tests/gateway/relay/test_relay_passthrough.py index 53e5424426..2150e9bf0b 100644 --- a/tests/gateway/relay/test_relay_passthrough.py +++ b/tests/gateway/relay/test_relay_passthrough.py @@ -17,7 +17,7 @@ import json import pytest -from gateway.config import PlatformConfig +from gateway.config import Platform, PlatformConfig from gateway.relay.adapter import RelayAdapter from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor from gateway.relay.ws_transport import PassthroughForward, _passthrough_from_wire @@ -120,9 +120,21 @@ async def test_discord_interaction_routes_through_handle_message(adapter, monkey assert ev.source.chat_id == "chan-9" assert ev.source.scope_id == "guild-7" assert ev.source.user_id == "user-3" - assert ev.source.chat_type == "channel" + # LOGICAL platform + native-parity chat_type: the session key must match + # the connector's capability-vault binding (interactionSessionSource → + # buildSessionKey: platform "discord", chat_type "group") and the relay + # text lane. Platform.RELAY / "channel" here forked the session and made + # /sethome file the home channel under platforms.relay (invisible to cron). + assert ev.source.platform == Platform.DISCORD + assert ev.source.chat_type == "group" + # Authenticated upstream-trust marker, parity with the relay text lane + # (ws_transport._event_from_wire) — /sethome's via_relay guard keys on it. + assert ev.source.delivered_via_upstream_relay is True # Scope captured so the agent's reply re-asserts scope_id for egress. assert adapter._scope_by_chat.get("chan-9") == "guild-7" + # The logical platform is now recorded for egress sender selection too + # (_capture_scope skips only the generic "relay"). + assert adapter._platform_by_chat.get("chan-9") == "discord" @pytest.mark.asyncio @@ -166,3 +178,35 @@ async def test_application_command_subcommand_nesting_renders_names_then_values( assert ev.get_command_args() == "run deploy" +@pytest.mark.asyncio +async def test_dm_interaction_keys_as_discord_dm(adapter, monkeypatch): + """A guild-less (DM) interaction keys as a Discord DM: logical platform, + chat_type 'dm', and the authenticated relay marker — the /sethome-in-DM + shape must file under platforms.discord, never platforms.relay.""" + await adapter.connect() + stub = adapter._transport + seen = [] + + async def fake_handle(event): + seen.append(event) + + monkeypatch.setattr(adapter, "handle_message", fake_handle) + fwd = _interaction_forward( + { + "id": "i-dm", + "type": 2, + "channel_id": "dm-chan-1", + "data": {"name": "sethome"}, + "user": {"id": "u9", "username": "ben"}, + } + ) + await stub.push_passthrough(fwd) + assert len(seen) == 1 + ev = seen[0] + assert ev.source.platform == Platform.DISCORD + assert ev.source.chat_type == "dm" + assert ev.source.scope_id is None + assert ev.source.user_id == "u9" + assert ev.source.delivered_via_upstream_relay is True + +