From e0dfcf275a22dfd1253c71074c5b6be780e3c965 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Sat, 25 Jul 2026 13:29:36 +1000 Subject: [PATCH] fix(relay): normalize forwarded Discord interactions to leading-slash commands (#71048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real APPLICATION_COMMAND interaction forwarded over the relay arrived slash-less: _discord_interaction_to_event set text = data['name'] ("new", not "/new"), MessageType.TEXT, and dropped options entirely — so a registered /new dispatched as plain chat instead of a command (MessageEvent.is_command() is text.startswith("/")). Port the connector's Slack slash-command precedent (normalizeSlackCommand builds `${command} ${args}`.trim() with a leading slash and explicit command type): for type-2 interactions build "/" + name, append rendered options space-separated (scalar options contribute their value, matching the native adapter's f"/model {name}" shape; SUB_COMMAND/ SUB_COMMAND_GROUP contribute their name then recurse into nested options), and set MessageType.COMMAND. Type-3 (custom_id) and other interaction types are unchanged. This implements the interaction->command sub-design previously flagged as deferred in the _on_passthrough docstring. Companion connector fix in gateway-gateway: fix(relay): strip own-mention prefix so addressed slash commands dispatch. --- gateway/relay/adapter.py | 59 ++++++- tests/gateway/relay/test_relay_passthrough.py | 147 +++++++++++++++++- 2 files changed, 197 insertions(+), 9 deletions(-) diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index 5937eb71bc16..c5da0fe3050f 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -369,12 +369,13 @@ class RelayAdapter(BasePlatformAdapter): NEVER raises: a malformed forward must not kill the read loop. - NOTE (open semantic sub-design, flagged for review): the interaction -> - MessageEvent mapping below is the v1 default. The exact agent UX for a - slash-command / button interaction (vs. a plain message) — command name - surfacing, option rendering, deferred-vs-immediate response — is the open - piece tracked in the spec; the TRANSPORT + receive mechanism (this whole - path) is settled. + Interaction -> MessageEvent command mapping (formerly flagged here as an + open sub-design, now implemented): an APPLICATION_COMMAND interaction is + normalized to a leading-slash COMMAND event ("/name arg…", mirroring the + connector's Slack slash-command lane, normalizeSlackCommand), so the + dispatcher routes it as a command instead of plain chat. Component + interactions (custom_id) still surface as best-effort TEXT; the + deferred-vs-immediate response UX remains connector-side. """ try: platform = getattr(forward, "platform", "") or "" @@ -417,8 +418,21 @@ class RelayAdapter(BasePlatformAdapter): # 3 = MESSAGE_COMPONENT; 5 = MODAL_SUBMIT. Surface a best-effort text. itype = payload.get("type") data = payload.get("data") or {} + message_type = MessageType.TEXT if itype == 2: - text = str(data.get("name") or "") + # Normalize a real slash-command interaction to a leading-slash + # command string — the shape the dispatcher (MessageEvent.is_command: + # text.startswith("/")) and the native Discord adapter's + # _run_simple_slash lane (f"/model {name}".strip()) both expect. + # Options render space-separated: scalar options contribute their + # value; SUB_COMMAND/SUB_COMMAND_GROUP (types 1/2) contribute their + # name then their nested options. Mirrors the connector's Slack + # slash lane (normalizeSlackCommand: `${command} ${args}`.trim()). + text = ("/" + str(data.get("name") or "")).rstrip("/") or "" + if text: + parts = [text] + self._render_interaction_options(data.get("options")) + text = " ".join(parts).strip() + message_type = MessageType.COMMAND elif itype == 3: text = str(data.get("custom_id") or "") else: @@ -436,7 +450,36 @@ class RelayAdapter(BasePlatformAdapter): scope_id=str(guild_id) if guild_id else None, # Discord guild → generic scope slot message_id=str(payload.get("id")) if payload.get("id") else None, ) - return MessageEvent(text=text, message_type=MessageType.TEXT, source=source) + return MessageEvent(text=text, message_type=message_type, source=source) + + @staticmethod + def _render_interaction_options(options) -> list: + """Render Discord interaction options to space-separated text parts. + + Discord's `data.options` is a list of {name, value, type}. Scalar + options (STRING/INTEGER/BOOLEAN/…) contribute just their value — + matching the native adapter's `f"/model {name}".strip()` shape, where + only the value follows the command. SUB_COMMAND (1) and + SUB_COMMAND_GROUP (2) contribute their *name* then recurse into their + nested `options` list (one level of nesting per Discord's schema: + group -> subcommand -> scalars). + """ + parts: list = [] + if not isinstance(options, list): + return parts + for opt in options: + if not isinstance(opt, dict): + continue + if opt.get("type") in (1, 2): # SUB_COMMAND / SUB_COMMAND_GROUP + sub_name = str(opt.get("name") or "").strip() + if sub_name: + parts.append(sub_name) + parts.extend(RelayAdapter._render_interaction_options(opt.get("options"))) + else: + value = opt.get("value") + if value is not None and str(value).strip(): + parts.append(str(value).strip()) + return parts async def disconnect(self) -> None: # Phase 7 Unit 7d-B: stop the revocation monitor first so it can't fire a diff --git a/tests/gateway/relay/test_relay_passthrough.py b/tests/gateway/relay/test_relay_passthrough.py index f59a6d6aee5f..21c0c7060e2e 100644 --- a/tests/gateway/relay/test_relay_passthrough.py +++ b/tests/gateway/relay/test_relay_passthrough.py @@ -118,7 +118,11 @@ async def test_discord_interaction_routes_through_handle_message(adapter, monkey assert len(seen) == 1 ev = seen[0] - assert ev.text == "summarize" + # APPLICATION_COMMAND interactions are normalized to a leading-slash + # command (the dispatcher's contract), not the bare registered name. + assert ev.text == "/summarize" + assert ev.is_command() is True + assert ev.get_command() == "summarize" assert ev.source.chat_id == "chan-9" assert ev.source.scope_id == "guild-7" assert ev.source.user_id == "user-3" @@ -151,6 +155,147 @@ async def test_message_component_interaction_uses_custom_id(adapter, monkeypatch await stub.push_passthrough(fwd) assert len(seen) == 1 assert seen[0].text == "approve_btn" + # Component interactions stay plain text — only APPLICATION_COMMANDs are + # normalized to slash commands. + assert seen[0].is_command() is False + + +@pytest.mark.asyncio +async def test_application_command_no_options_is_slash_command(adapter, monkeypatch): + """/new with no options -> text '/new', dispatched as a COMMAND event.""" + from gateway.platforms.base import MessageType + + 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-new", + "type": 2, + "channel_id": "c3", + "guild_id": "g3", + "data": {"name": "new"}, + "member": {"user": {"id": "u3", "username": "ben"}}, + } + ) + await stub.push_passthrough(fwd) + assert len(seen) == 1 + ev = seen[0] + assert ev.text == "/new" + assert ev.message_type == MessageType.COMMAND + # Behavior contract: the dispatcher must recognize this as command 'new'. + assert ev.is_command() is True + assert ev.get_command() == "new" + assert ev.get_command_args() == "" + + +@pytest.mark.asyncio +async def test_application_command_scalar_options_append_values(adapter, monkeypatch): + """Scalar options append their values space-separated: /model gpt-x.""" + 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-model", + "type": 2, + "channel_id": "c4", + "guild_id": "g4", + "data": { + "name": "model", + "options": [{"name": "name", "type": 3, "value": "gpt-x"}], + }, + "member": {"user": {"id": "u4", "username": "ben"}}, + } + ) + await stub.push_passthrough(fwd) + assert len(seen) == 1 + ev = seen[0] + assert ev.text == "/model gpt-x" + assert ev.is_command() is True + assert ev.get_command() == "model" + assert ev.get_command_args() == "gpt-x" + + +@pytest.mark.asyncio +async def test_application_command_subcommand_nesting_renders_names_then_values( + adapter, monkeypatch +): + """SUB_COMMAND (type 1) appends its name, then recurses into its options.""" + 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-sub", + "type": 2, + "channel_id": "c5", + "guild_id": "g5", + "data": { + "name": "skill", + "options": [ + { + "name": "run", + "type": 1, # SUB_COMMAND + "options": [{"name": "target", "type": 3, "value": "deploy"}], + } + ], + }, + "member": {"user": {"id": "u5", "username": "ben"}}, + } + ) + await stub.push_passthrough(fwd) + assert len(seen) == 1 + ev = seen[0] + assert ev.text == "/skill run deploy" + assert ev.is_command() is True + assert ev.get_command() == "skill" + assert ev.get_command_args() == "run deploy" + + +@pytest.mark.asyncio +async def test_ping_interaction_produces_no_command(adapter, monkeypatch): + """A PING (type 1) body — never normally forwarded — stays empty TEXT, not + a phantom command.""" + from gateway.platforms.base import MessageType + + 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-ping", + "type": 1, # PING + "channel_id": "c6", + "user": {"id": "u6", "username": "ben"}, + } + ) + await stub.push_passthrough(fwd) + assert len(seen) == 1 + ev = seen[0] + assert ev.text == "" + assert ev.message_type == MessageType.TEXT + assert ev.is_command() is False @pytest.mark.asyncio