Skip to content

Commit 731e37c

Browse files
committed
fix(discord): handle interactions in gateway-only mode (vercel/chat#490)
Port of upstream b9b17cd. Discord sends interactions through either the Gateway or an Interactions Endpoint URL, not both — deployments without an endpoint URL receive interactions over the Gateway, and the adapter previously dropped them ("Forwarded Gateway event (no handler)"). The Python adapter is HTTP-interactions-only with a gateway-forwarder receiver, so the port lands on that surface: a forwarded GATEWAY_INTERACTION_CREATE event (raw wire-format INTERACTION_CREATE dispatch payload) is now acknowledged via the interaction callback REST endpoint — POST /interactions/{id}/{token}/callback with type 5 for slash commands / type 6 for components, the same wire calls upstream's resident discord.js handler makes via deferReply() / deferUpdate() — then routed through the existing slash-command and action handler paths. Deferred slash responses resolve exactly like HTTP ones (post_message PATCHes the @original webhook message). Callback path segments are URL-quoted (hazard #12) and defer failures are logged without dispatching the handler, matching upstream's listener-level catch. Also aligns slash-command boolean option flattening with TS String(true) — "status true", not Python str(True) "status True" — pinned by the ported upstream test. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 (cherry picked from commit 458428afc383b57704e6fe7c6d8abde23235afda)
1 parent bb2e81b commit 731e37c

4 files changed

Lines changed: 332 additions & 3 deletions

File tree

src/chat_sdk/adapters/discord/adapter.py

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -505,10 +505,20 @@ def _parse_slash_command(
505505
command_parts: list[str] = [name if name.startswith("/") else f"/{name}"]
506506
value_parts: list[str] = []
507507

508+
def stringify(value: Any) -> str:
509+
# Match TS `String(value)` for boolean options: JSON booleans
510+
# arrive as Python bools, and `str(True)` would emit "True"
511+
# where upstream emits "true" (e.g. a `verbose: true` option).
512+
if value is True:
513+
return "true"
514+
if value is False:
515+
return "false"
516+
return str(value)
517+
508518
def collect(items: list[DiscordCommandOption]) -> None:
509519
for option in items:
510520
if option.get("value") is not None:
511-
value_parts.append(str(option["value"]))
521+
value_parts.append(stringify(option["value"]))
512522
continue
513523
sub_options = option.get("options", [])
514524
if sub_options:
@@ -541,11 +551,110 @@ async def _handle_forwarded_gateway_event(
541551
await self._handle_forwarded_reaction(event.get("data", {}), True, options)
542552
elif event_type == "GATEWAY_MESSAGE_REACTION_REMOVE":
543553
await self._handle_forwarded_reaction(event.get("data", {}), False, options)
554+
elif event_type == "GATEWAY_INTERACTION_CREATE":
555+
await self._handle_forwarded_interaction(event.get("data", {}), options)
544556
else:
545557
self._logger.debug("Forwarded Gateway event (no handler)", {"type": event_type})
546558

547559
return self._make_json_response(json.dumps({"ok": True}), 200)
548560

561+
async def _handle_forwarded_interaction(
562+
self,
563+
interaction: DiscordInteraction,
564+
options: WebhookOptions | None = None,
565+
) -> None:
566+
"""Handle a forwarded INTERACTION_CREATE event (gateway-only mode).
567+
568+
Discord sends interactions through either the Gateway or an
569+
Interactions Endpoint URL, not both (vercel/chat#490). Deployments
570+
that leave the endpoint URL unset receive interactions over the
571+
Gateway; the forwarder relays the raw INTERACTION_CREATE dispatch
572+
payload here, which is already in wire format, so the existing HTTP
573+
interaction handlers consume it unchanged.
574+
575+
Unlike HTTP interactions -- where the deferral rides the HTTP
576+
response body -- a gateway interaction is acknowledged with an
577+
explicit REST call to the interaction callback endpoint. That is
578+
the same wire call upstream's gateway-only handler makes via
579+
discord.js ``deferReply()`` (slash commands) and ``deferUpdate()``
580+
(components). Slash commands then route through the existing slash
581+
command handler path (the deferred response is later resolved by
582+
``post_message`` PATCHing the ``@original`` interaction webhook
583+
message), and component interactions route through the existing
584+
action handler path.
585+
"""
586+
interaction_id = interaction.get("id")
587+
interaction_token = interaction.get("token")
588+
interaction_type = interaction.get("type", 0)
589+
590+
self._logger.info(
591+
"Discord Gateway interaction received",
592+
{"id": interaction_id, "type": interaction_type},
593+
)
594+
595+
if interaction_type not in (
596+
INTERACTION_TYPE_APPLICATION_COMMAND,
597+
INTERACTION_TYPE_MESSAGE_COMPONENT,
598+
):
599+
self._logger.debug(
600+
"Forwarded Gateway interaction (no handler)",
601+
{"type": interaction_type},
602+
)
603+
return
604+
605+
if not (interaction_id and interaction_token):
606+
# A gateway INTERACTION_CREATE always carries id + token; a
607+
# malformed forward must not produce a garbage callback URL.
608+
self._logger.warn(
609+
"Forwarded Gateway interaction missing id or token",
610+
{"id": interaction_id, "type": interaction_type},
611+
)
612+
return
613+
614+
try:
615+
if interaction_type == INTERACTION_TYPE_APPLICATION_COMMAND:
616+
# deferReply: ACK now, respond via the interaction webhook later.
617+
await self._defer_gateway_interaction(
618+
interaction_id,
619+
interaction_token,
620+
InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
621+
)
622+
self._handle_application_command_interaction(interaction, options)
623+
return
624+
625+
# deferUpdate: ACK the component, update the message later.
626+
await self._defer_gateway_interaction(
627+
interaction_id,
628+
interaction_token,
629+
InteractionResponseType.DEFERRED_UPDATE_MESSAGE,
630+
)
631+
self._handle_component_interaction(interaction, options)
632+
except Exception as error:
633+
self._logger.error(
634+
"Error handling Gateway interaction",
635+
{"error": str(error), "interactionId": interaction_id},
636+
)
637+
638+
async def _defer_gateway_interaction(
639+
self,
640+
interaction_id: str,
641+
interaction_token: str,
642+
response_type: int,
643+
) -> None:
644+
"""ACK a gateway-received interaction via the callback endpoint.
645+
646+
``POST /interactions/{id}/{token}/callback`` is the REST equivalent
647+
of returning the deferral as the HTTP response body on the
648+
Interactions Endpoint path. Path segments are URL-quoted so a
649+
crafted id/token in a forwarded payload cannot pivot the request
650+
(hazard #12, same guard as :meth:`get_user`).
651+
"""
652+
await self._discord_fetch(
653+
f"/interactions/{quote(interaction_id, safe='')}/{quote(interaction_token, safe='')}/callback",
654+
"POST",
655+
{"type": response_type},
656+
)
657+
549658
async def _handle_forwarded_message(
550659
self,
551660
data: DiscordGatewayMessageData,

src/chat_sdk/adapters/discord/types.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,13 @@ class DiscordGatewayReactionData(TypedDict, total=False):
306306

307307

308308
class DiscordForwardedEvent(TypedDict):
309-
"""A Gateway event forwarded to the webhook endpoint."""
309+
"""A Gateway event forwarded to the webhook endpoint.
310+
311+
Known types: ``GATEWAY_MESSAGE_CREATE``, ``GATEWAY_MESSAGE_REACTION_ADD``,
312+
``GATEWAY_MESSAGE_REACTION_REMOVE``, ``GATEWAY_INTERACTION_CREATE``.
313+
For ``GATEWAY_INTERACTION_CREATE``, ``data`` is the raw wire-format
314+
INTERACTION_CREATE dispatch payload (:class:`DiscordInteraction`).
315+
"""
310316

311317
data: Any
312318
timestamp: int

tests/test_discord_adapter.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,9 @@ async def test_dispatches_slash_command_to_chat(self):
406406
mock_chat.process_slash_command.assert_called_once()
407407
call_args = mock_chat.process_slash_command.call_args[0][0]
408408
assert call_args.command == "/test"
409-
assert call_args.text == "status True"
409+
# Boolean option values flatten as JSON-style "true"/"false",
410+
# matching TS `String(true)` (wire parity, vercel/chat#490 test).
411+
assert call_args.text == "status true"
410412

411413
@pytest.mark.asyncio
412414
async def test_expands_subcommand_path(self):

tests/test_discord_extended.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -923,6 +923,218 @@ async def test_handles_reaction_remove(self):
923923
assert call_args.message_id == "msg123"
924924

925925

926+
# ============================================================================
927+
# Forwarded gateway interactions (gateway-only mode, vercel/chat#490)
928+
# ============================================================================
929+
930+
931+
class TestForwardedGatewayInteractions:
932+
"""Port of the "legacy gateway interactions" describe block.
933+
934+
Discord sends interactions through either the Gateway or an
935+
Interactions Endpoint URL, not both. In gateway-only deployments the
936+
forwarder relays the raw INTERACTION_CREATE payload; the adapter must
937+
defer it via the interaction callback REST endpoint (the wire call
938+
discord.js makes for deferReply/deferUpdate) and route through the
939+
existing slash-command / action handler paths.
940+
"""
941+
942+
def _slash_interaction(self, **overrides):
943+
interaction = {
944+
"id": "interaction123",
945+
"application_id": "test-app-id",
946+
"token": "interaction-token",
947+
"type": 2, # APPLICATION_COMMAND
948+
"version": 1,
949+
"guild_id": "guild123",
950+
"channel_id": "channel456",
951+
"channel": {"id": "channel456", "type": 0},
952+
"user": {
953+
"id": "user789",
954+
"username": "testuser",
955+
"discriminator": "0001",
956+
"global_name": "Test User",
957+
"bot": False,
958+
},
959+
"data": {
960+
"name": "test",
961+
"type": 1,
962+
"options": [
963+
{"name": "topic", "type": 3, "value": "status"},
964+
{"name": "verbose", "type": 5, "value": True},
965+
],
966+
},
967+
}
968+
interaction.update(overrides)
969+
return interaction
970+
971+
def _component_interaction(self, **overrides):
972+
interaction = {
973+
"id": "interaction123",
974+
"application_id": "test-app-id",
975+
"token": "interaction-token",
976+
"type": 3, # MESSAGE_COMPONENT
977+
"version": 1,
978+
"guild_id": "guild123",
979+
"channel_id": "channel456",
980+
"channel": {"id": "channel456", "type": 0},
981+
"user": {
982+
"id": "user789",
983+
"username": "testuser",
984+
"discriminator": "0001",
985+
"global_name": "Test User",
986+
"bot": False,
987+
},
988+
"data": {"custom_id": "approve_btn", "component_type": 2},
989+
"message": {"id": "message123"},
990+
}
991+
interaction.update(overrides)
992+
return interaction
993+
994+
def _forwarded(self, interaction) -> str:
995+
return json.dumps(
996+
{
997+
"type": "GATEWAY_INTERACTION_CREATE",
998+
"timestamp": 1234567890,
999+
"data": interaction,
1000+
}
1001+
)
1002+
1003+
@pytest.mark.asyncio
1004+
async def test_handles_slash_command_interactions_from_the_gateway(self):
1005+
adapter = _make_adapter(logger=_make_logger())
1006+
mock_chat = MagicMock()
1007+
mock_chat.process_slash_command = MagicMock()
1008+
adapter._chat = mock_chat
1009+
adapter._discord_fetch = AsyncMock(return_value=None)
1010+
1011+
response = await adapter.handle_webhook(_gateway_request(self._forwarded(self._slash_interaction())))
1012+
1013+
assert response["status"] == 200
1014+
# deferReply: explicit callback REST call (type 5)
1015+
adapter._discord_fetch.assert_awaited_once_with(
1016+
"/interactions/interaction123/interaction-token/callback",
1017+
"POST",
1018+
{"type": 5},
1019+
)
1020+
mock_chat.process_slash_command.assert_called_once()
1021+
event = mock_chat.process_slash_command.call_args[0][0]
1022+
assert event.command == "/test"
1023+
assert event.text == "status true"
1024+
assert event.channel_id == "discord:guild123:channel456"
1025+
assert event.user.user_id == "user789"
1026+
assert event.user.user_name == "testuser"
1027+
assert event.user.full_name == "Test User"
1028+
1029+
@pytest.mark.asyncio
1030+
async def test_handles_component_interactions_from_the_gateway(self):
1031+
adapter = _make_adapter(logger=_make_logger())
1032+
mock_chat = MagicMock()
1033+
mock_chat.process_action = MagicMock()
1034+
adapter._chat = mock_chat
1035+
adapter._discord_fetch = AsyncMock(return_value=None)
1036+
1037+
response = await adapter.handle_webhook(_gateway_request(self._forwarded(self._component_interaction())))
1038+
1039+
assert response["status"] == 200
1040+
# deferUpdate: explicit callback REST call (type 6)
1041+
adapter._discord_fetch.assert_awaited_once_with(
1042+
"/interactions/interaction123/interaction-token/callback",
1043+
"POST",
1044+
{"type": 6},
1045+
)
1046+
mock_chat.process_action.assert_called_once()
1047+
event = mock_chat.process_action.call_args[0][0]
1048+
assert event.action_id == "approve_btn"
1049+
assert event.value == "approve_btn"
1050+
assert event.message_id == "message123"
1051+
assert event.thread_id == "discord:guild123:channel456"
1052+
1053+
@pytest.mark.asyncio
1054+
async def test_slash_command_defer_failure_skips_handler(self):
1055+
"""If the deferral REST call fails the handler must not run --
1056+
matches upstream where a deferReply rejection is caught and logged
1057+
before the normalize+handle step."""
1058+
logger = _make_logger()
1059+
adapter = _make_adapter(logger=logger)
1060+
mock_chat = MagicMock()
1061+
mock_chat.process_slash_command = MagicMock()
1062+
adapter._chat = mock_chat
1063+
adapter._discord_fetch = AsyncMock(side_effect=NetworkError("discord", "boom"))
1064+
1065+
response = await adapter.handle_webhook(_gateway_request(self._forwarded(self._slash_interaction())))
1066+
1067+
# Forwarded-event responses stay 200; the failure is logged.
1068+
assert response["status"] == 200
1069+
mock_chat.process_slash_command.assert_not_called()
1070+
error_messages = [c.args[0] for c in logger.error.call_args_list]
1071+
assert "Error handling Gateway interaction" in error_messages
1072+
1073+
@pytest.mark.asyncio
1074+
async def test_unhandled_interaction_type_is_ignored_without_defer(self):
1075+
"""PING/autocomplete-style interactions are not deferred or routed."""
1076+
adapter = _make_adapter(logger=_make_logger())
1077+
mock_chat = MagicMock()
1078+
mock_chat.process_slash_command = MagicMock()
1079+
mock_chat.process_action = MagicMock()
1080+
adapter._chat = mock_chat
1081+
adapter._discord_fetch = AsyncMock(return_value=None)
1082+
1083+
response = await adapter.handle_webhook(_gateway_request(self._forwarded(self._slash_interaction(type=4))))
1084+
1085+
assert response["status"] == 200
1086+
adapter._discord_fetch.assert_not_awaited()
1087+
mock_chat.process_slash_command.assert_not_called()
1088+
mock_chat.process_action.assert_not_called()
1089+
1090+
@pytest.mark.asyncio
1091+
async def test_interaction_missing_token_is_not_deferred_or_routed(self):
1092+
"""A malformed forward without id/token must not produce a garbage
1093+
callback URL or dispatch a handler."""
1094+
adapter = _make_adapter(logger=_make_logger())
1095+
mock_chat = MagicMock()
1096+
mock_chat.process_action = MagicMock()
1097+
adapter._chat = mock_chat
1098+
adapter._discord_fetch = AsyncMock(return_value=None)
1099+
1100+
interaction = self._component_interaction()
1101+
del interaction["token"]
1102+
response = await adapter.handle_webhook(_gateway_request(self._forwarded(interaction)))
1103+
1104+
assert response["status"] == 200
1105+
adapter._discord_fetch.assert_not_awaited()
1106+
mock_chat.process_action.assert_not_called()
1107+
1108+
@pytest.mark.asyncio
1109+
async def test_gateway_slash_command_supports_deferred_response_flow(self):
1110+
"""The gateway-deferred slash interaction resolves like an HTTP one:
1111+
the handler's first post_message PATCHes the @original webhook
1112+
message using the interaction token."""
1113+
adapter = _make_adapter(logger=_make_logger())
1114+
mock_chat = MagicMock()
1115+
1116+
def run_handler(event, options=None):
1117+
# Simulate Chat dispatching a handler that replies immediately.
1118+
import asyncio as _asyncio
1119+
1120+
task = _asyncio.get_running_loop().create_task(adapter.post_message(event.channel_id, "reply from handler"))
1121+
run_handler.task = task
1122+
1123+
mock_chat.process_slash_command = MagicMock(side_effect=run_handler)
1124+
adapter._chat = mock_chat
1125+
adapter._discord_fetch = AsyncMock(return_value={"id": "reply-msg-1"})
1126+
1127+
await adapter.handle_webhook(_gateway_request(self._forwarded(self._slash_interaction())))
1128+
await run_handler.task
1129+
1130+
paths = [c.args[0] for c in adapter._discord_fetch.await_args_list]
1131+
# First the deferral, then the @original PATCH (not a channel POST).
1132+
assert paths[0] == "/interactions/interaction123/interaction-token/callback"
1133+
assert paths[1] == "/webhooks/test-app-id/interaction-token/messages/@original"
1134+
patch_call = adapter._discord_fetch.await_args_list[1]
1135+
assert patch_call.args[1] == "PATCH"
1136+
1137+
9261138
# ============================================================================
9271139
# Forwarded message -- thread detection
9281140
# ============================================================================

0 commit comments

Comments
 (0)