Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 51 additions & 8 deletions gateway/relay/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,12 +361,13 @@ async def _on_passthrough(self, forward, buffer_id: Optional[str] = None) -> Non

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 ""
Expand Down Expand Up @@ -409,8 +410,21 @@ def _discord_interaction_to_event(self, forward):
# 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:
Expand All @@ -428,7 +442,36 @@ def _discord_interaction_to_event(self, forward):
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
Expand Down
147 changes: 146 additions & 1 deletion tests/gateway/relay/test_relay_passthrough.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,11 @@ async def fake_handle(event):

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"
Expand Down Expand Up @@ -151,6 +155,147 @@ async def fake_handle(event):
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
Expand Down
Loading