mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-04 14:52:12 +08:00
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 <[email protected]> Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
multica-agent
parent
9238833fa4
commit
a970179e11
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user