mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-25 17:18:11 +00:00
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:
parent
666824261a
commit
e0dfcf275a
2 changed files with 197 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue