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:
RockChinQ
2026-11-03 20:55:05 +08:00
co-authored by Claude Opus 5.5 multica-agent
parent 9238833fa4
commit a970179e11
3 changed files with 93 additions and 7 deletions
@@ -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),