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