fix(relay): normalize forwarded Discord interactions to leading-slash commands (#71048)

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.
This commit is contained in:
Ben Barclay 2026-07-25 13:29:36 +10:00 committed by GitHub
parent 666824261a
commit e0dfcf275a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 197 additions and 9 deletions

View file

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

View file

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