From a970179e1125763930759099eaa99d7d4441fac0 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Tue, 3 Nov 2026 20:55:05 +0800 Subject: [PATCH] fix(discord): deliver guild interaction cards to the originating channel Pipeline group conversations are keyed by guild id, so send_interaction targeted the guild and Discord returned Unknown Channel. Resolve the channel from the message being answered, edit cards in that channel, and report the card's conversation on callbacks so the Host scope check matches. Co-Authored-By: Claude Opus 5.5 Co-authored-by: multica-agent --- .../pkg/platform/adapters/discord/adapter.py | 42 ++++++++++++++++++- .../platform/adapters/discord/interaction.py | 17 ++++++-- .../platform/test_discord_eba_adapter.py | 41 +++++++++++++++++- 3 files changed, 93 insertions(+), 7 deletions(-) diff --git a/src/langbot/pkg/platform/adapters/discord/adapter.py b/src/langbot/pkg/platform/adapters/discord/adapter.py index 206275bbb..6bd1cefa1 100644 --- a/src/langbot/pkg/platform/adapters/discord/adapter.py +++ b/src/langbot/pkg/platform/adapters/discord/adapter.py @@ -1,5 +1,6 @@ from __future__ import annotations +import collections import os import time import traceback @@ -37,6 +38,13 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform _stream_buffer: dict = pydantic.PrivateAttr(default_factory=dict) _STREAM_EDIT_INTERVAL: typing.ClassVar[int] = 8 + # Guild conversations are keyed by guild id, so interaction replies resolve + # their channel from the message they answer. + _message_channels: collections.OrderedDict = pydantic.PrivateAttr(default_factory=collections.OrderedDict) + # Callbacks must report the conversation the card was sent for, which may be + # a guild rather than the channel the button lives in. + _interaction_targets: collections.OrderedDict = pydantic.PrivateAttr(default_factory=collections.OrderedDict) + _MESSAGE_CHANNEL_LIMIT: typing.ClassVar[int] = 1024 config: dict listeners: dict[ typing.Type[platform_events.Event], @@ -59,6 +67,7 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform return if message.author.bot: return + adapter_self._remember_message_channel(message) try: if ( platform_events.FriendMessage in adapter_self.listeners @@ -90,7 +99,12 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform if not interaction.response.is_done(): await interaction.response.defer() try: - await adapter_self._dispatch_eba_event(interaction_event_from_component(interaction, parsed)) + conversation = adapter_self._interaction_targets.get( + str(interaction.message.id) if interaction.message is not None else '' + ) + await adapter_self._dispatch_eba_event( + interaction_event_from_component(interaction, parsed, conversation) + ) if interaction.message is not None: await interaction.message.edit(view=None) except Exception: @@ -237,8 +251,32 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform handle = InteractionHandle.model_validate(result) handle.context['request'] = request.model_dump(mode='json') handle.context['target_id'] = target_id + self._remember(self._interaction_targets, str(handle.message_id), (target_type, str(target_id))) return handle + def _remember_message_channel(self, message: discord.Message) -> None: + self._remember(self._message_channels, str(message.id), message.channel.id) + + def _remember(self, cache: collections.OrderedDict, key: str, value: typing.Any) -> None: + cache[key] = value + cache.move_to_end(key) + while len(cache) > self._MESSAGE_CHANNEL_LIMIT: + cache.popitem(last=False) + + async def _resolve_interaction_channel( + self, target_type: str, target_id: str, reply_to: str | None + ) -> discord.abc.Messageable: + channel_id = self._message_channels.get(str(reply_to)) if reply_to is not None else None + if channel_id is not None: + return await self._get_channel(channel_id) + guild = self.bot.get_guild(int(target_id)) if target_type == 'group' else None + if guild is not None: + # Match send_message: without an originating message, use the system channel. + if guild.system_channel is None: + raise ValueError('Discord guild has no system channel configured for interactions') + return guild.system_channel + return await self._get_channel(target_id) + async def update_interaction( self, handle: InteractionHandle, @@ -246,7 +284,7 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform submission: InteractionSubmission | None = None, ) -> InteractionHandle: del submission - channel = await self._get_channel(str(handle.context['target_id'])) + channel = await self._get_channel(str(handle.context.get('channel_id') or handle.context['target_id'])) message = await channel.fetch_message(int(handle.message_id)) label = {'submitted': 'Submitted', 'expired': 'Expired', 'cancelled': 'Cancelled'}[state] await message.edit(content=f'{handle.context["request"]["title"]}\n\n{label}', view=None) diff --git a/src/langbot/pkg/platform/adapters/discord/interaction.py b/src/langbot/pkg/platform/adapters/discord/interaction.py index a02a594d2..a09efd2fa 100644 --- a/src/langbot/pkg/platform/adapters/discord/interaction.py +++ b/src/langbot/pkg/platform/adapters/discord/interaction.py @@ -104,24 +104,33 @@ async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) - target_id = str(reply_target.get('target_id') or '') if not target_id: raise ValueError('send_interaction has no target_id') - channel = await adapter._get_channel(target_id) + channel = await adapter._resolve_interaction_channel( + str(reply_target.get('target_type') or ''), target_id, reply_target.get('message_id') + ) view = build_interaction_view(request, callback_token) sent = await channel.send(content=_content(request, rich=view is not None), view=view) - return {'ok': True, 'message_id': sent.id, 'rich': view is not None} + return {'ok': True, 'message_id': sent.id, 'rich': view is not None, 'context': {'channel_id': str(channel.id)}} def interaction_event_from_component( interaction: discord.Interaction, parsed: dict[str, typing.Any], + conversation: tuple[str, str] | None = None, ) -> platform_events.InteractionCallbackEvent: + """Build a callback event; ``conversation`` is the target the card was sent for.""" if interaction.user is None or interaction.channel is None: raise ValueError('Discord interaction has no actor or channel') + target_type, target_id = conversation or ( + 'group' if interaction.guild is not None else 'person', + str(interaction.channel.id), + ) return platform_events.InteractionCallbackEvent( adapter_name='discord-omni', callback_token=str(parsed.get('callback_token') or ''), actor_id=str(interaction.user.id), - target_type='group' if interaction.guild is not None else 'person', - target_id=str(interaction.channel.id), + target_type=target_type, + target_id=target_id, + callback_context={'channel_id': str(interaction.channel.id)}, raw_submission=parsed, timestamp=time.time(), source_platform_object=interaction, diff --git a/tests/unit_tests/platform/test_discord_eba_adapter.py b/tests/unit_tests/platform/test_discord_eba_adapter.py index b3664fa29..a84fc92cb 100644 --- a/tests/unit_tests/platform/test_discord_eba_adapter.py +++ b/tests/unit_tests/platform/test_discord_eba_adapter.py @@ -89,7 +89,7 @@ def test_discord_interaction_custom_id_parser_uses_host_indexes(): @pytest.mark.asyncio async def test_discord_interaction_view_and_delivery(): adapter = make_adapter() - channel = SimpleNamespace(send=AsyncMock(return_value=SimpleNamespace(id=321))) + channel = SimpleNamespace(id=789, send=AsyncMock(return_value=SimpleNamespace(id=321))) adapter._get_channel = AsyncMock(return_value=channel) request = { 'interaction_id': 'form-1', @@ -107,9 +107,48 @@ async def test_discord_interaction_view_and_delivery(): assert isinstance(result, InteractionHandle) assert result.message_id == 321 and result.rich is True + assert result.context['channel_id'] == '789' assert channel.send.await_args.kwargs['view'].children[0].label == 'Approve' +@pytest.mark.asyncio +async def test_discord_guild_interaction_targets_the_originating_channel(): + adapter = make_adapter() + channel = SimpleNamespace(id=555, send=AsyncMock(return_value=SimpleNamespace(id=321))) + adapter._get_channel = AsyncMock(return_value=channel) + adapter._remember_message_channel(SimpleNamespace(id=42, channel=SimpleNamespace(id=555))) + request = InteractionRequest.model_validate( + {'interaction_id': 'form-1', 'title': 'Approve?', 'actions': [{'id': 'approve', 'label': 'Approve'}]} + ) + + # Pipeline group conversations are keyed by guild id; the card must go to the message's channel. + result = await adapter.send_interaction('group', '1558675416288333856', request, 'callback-token', reply_to='42') + + adapter._get_channel.assert_awaited_once_with(555) + assert result.context['channel_id'] == '555' + assert result.context['target_id'] == '1558675416288333856' + + message = SimpleNamespace(edit=AsyncMock()) + channel.fetch_message = AsyncMock(return_value=message) + adapter._get_channel.reset_mock() + await adapter.update_interaction(result, 'submitted') + adapter._get_channel.assert_awaited_once_with('555') + message.edit.assert_awaited_once_with(content='Approve?\n\nSubmitted', view=None) + + # The click happens in the channel, but the Host recorded the guild conversation. + interaction = SimpleNamespace( + user=SimpleNamespace(id=7), + channel=SimpleNamespace(id=555), + guild=SimpleNamespace(id=1558675416288333856), + message=SimpleNamespace(id=321), + ) + event = interaction_event_from_component( + interaction, {'callback_token': 'callback-token', 'action_ref': 0}, adapter._interaction_targets.get('321') + ) + assert (event.target_type, event.target_id) == ('group', '1558675416288333856') + assert event.callback_context == {'channel_id': '555'} + + def test_discord_interaction_component_builds_scoped_host_event(): interaction = SimpleNamespace( user=SimpleNamespace(id=123),