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),