Skip to content

Commit a0dfdb9

Browse files
committed
Fix multiple view instances not dispatching in app commands responses
Due to a quirk in InteractionResponse.send_message not returning a message, all messages sent with an associated View would end up having no message_id set. When multiple instances of a View are responded to in a slash command context, this meant that the newest one would override the storage of the older one. Ultimately leading to the first view instance causing interaction failures. Since fetching the original message is an unacceptable solution to the problem due to incurred requests, the next best thing is to store an intermediate interaction_id as a stop gap to differentiate between the multiple instances. This change however, came with its own set of complications. Due to the interaction_id being an intermediate stop gap, the underlying storage of the view store had to be changed to accommodate the different way of accessing the data. Mainly, the interaction_id key had to be quick to swap and remove to another key. This solution attempts to change the interaction_id interim key with a full fledged message_id key when it receives one if it's possible. Note that the only way to obtain the interaction_id back from the component interaction is to retrieve it from the MessageInteraction data structure. This is because using the interaction_id of the button press would be a different interaction ID than the one set as an interim key. As a consequence, this stop gap only works for application command based interactions. I am not aware of this bug manifesting in component based interactions. This patch also fixes a bug with ViewStore.remove_view not working due to a bug being suppressed by a type: ignore comment. It also removes the older __verify_integrity helper method since clean-up is already done through View.stop() or View timeout. Hopefully in the near future, the `/callback` endpoint will return actual message data and this stop gap fix will no longer be necessary.
1 parent e53daab commit a0dfdb9

File tree

4 files changed

+85
-39
lines changed

4 files changed

+85
-39
lines changed

discord/interactions.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,7 @@ async def edit_original_message(
454454
state = _InteractionMessageState(self, self._state)
455455
message = InteractionMessage(state=state, channel=self.channel, data=data) # type: ignore
456456
if view and not view.is_finished():
457-
self._state.store_view(view, message.id)
457+
self._state.store_view(view, message.id, interaction_id=self.id)
458458
return message
459459

460460
async def delete_original_message(self) -> None:
@@ -682,7 +682,10 @@ async def send_message(
682682
if ephemeral and view.timeout is None:
683683
view.timeout = 15 * 60.0
684684

685-
self._parent._state.store_view(view)
685+
# If the interaction type isn't an application command then there's no way
686+
# to obtain this interaction_id again, so just default to None
687+
entity_id = parent.id if parent.type is InteractionType.application_command else None
688+
self._parent._state.store_view(view, entity_id)
686689

687690
self._responded = True
688691

discord/message.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -882,7 +882,11 @@ async def edit(
882882
message = Message(state=self._state, channel=self.channel, data=data)
883883

884884
if view and not view.is_finished():
885-
self._state.store_view(view, self.id)
885+
interaction: Optional[MessageInteraction] = getattr(self, 'interaction', None)
886+
if interaction is not None:
887+
self._state.store_view(view, self.id, interaction_id=interaction.id)
888+
else:
889+
self._state.store_view(view, self.id)
886890

887891
if delete_after is not None:
888892
await self.delete(delay=delete_after)

discord/state.py

+11-3
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,9 @@ def store_sticker(self, guild: Guild, data: GuildStickerPayload) -> GuildSticker
361361
self._stickers[sticker_id] = sticker = GuildSticker(state=self, data=data)
362362
return sticker
363363

364-
def store_view(self, view: View, message_id: Optional[int] = None) -> None:
364+
def store_view(self, view: View, message_id: Optional[int] = None, interaction_id: Optional[int] = None) -> None:
365+
if interaction_id is not None:
366+
self._view_store.remove_interaction_mapping(interaction_id)
365367
self._view_store.add_view(view, message_id)
366368

367369
def prevent_view_updates_for(self, message_id: int) -> Optional[View]:
@@ -631,8 +633,14 @@ def parse_message_update(self, data: gw.MessageUpdateEvent) -> None:
631633
else:
632634
self.dispatch('raw_message_edit', raw)
633635

634-
if 'components' in data and self._view_store.is_message_tracked(raw.message_id):
635-
self._view_store.update_from_message(raw.message_id, data['components'])
636+
if 'components' in data:
637+
try:
638+
entity_id = int(data['interaction']['id'])
639+
except (KeyError, ValueError):
640+
entity_id = raw.message_id
641+
642+
if self._view_store.is_message_tracked(entity_id):
643+
self._view_store.update_from_message(entity_id, data['components'])
636644

637645
def parse_message_reaction_add(self, data: gw.MessageReactionAddEvent) -> None:
638646
emoji = PartialEmoji.from_dict(data['emoji'])

discord/ui/view.py

+64-33
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ def __init__(self, *, timeout: Optional[float] = 180.0):
184184
self._children: List[Item[Self]] = self._init_children()
185185
self.__weights = _ViewWeights(self._children)
186186
self.id: str = os.urandom(16).hex()
187+
self._cache_key: Optional[int] = None
187188
self.__cancel_callback: Optional[Callable[[View], None]] = None
188189
self.__timeout_expiry: Optional[float] = None
189190
self.__timeout_task: Optional[asyncio.Task[None]] = None
@@ -512,8 +513,8 @@ async def wait(self) -> bool:
512513

513514
class ViewStore:
514515
def __init__(self, state: ConnectionState):
515-
# (component_type, message_id, custom_id): (View, Item)
516-
self._views: Dict[Tuple[int, Optional[int], str], Tuple[View, Item]] = {}
516+
# entity_id: {(component_type, custom_id): Item}
517+
self._views: Dict[Optional[int], Dict[Tuple[int, str], Item[View]]] = {}
517518
# message_id: View
518519
self._synced_message_views: Dict[int, View] = {}
519520
# custom_id: Modal
@@ -524,34 +525,26 @@ def __init__(self, state: ConnectionState):
524525
def persistent_views(self) -> Sequence[View]:
525526
# fmt: off
526527
views = {
527-
view.id: view
528-
for (_, (view, _)) in self._views.items()
529-
if view.is_persistent()
528+
item.view.id: item.view
529+
for items in self._views.values()
530+
for item in items.values()
531+
if item.view and item.view.is_persistent()
530532
}
531533
# fmt: on
532534
return list(views.values())
533535

534-
def __verify_integrity(self):
535-
to_remove: List[Tuple[int, Optional[int], str]] = []
536-
for (k, (view, _)) in self._views.items():
537-
if view.is_finished():
538-
to_remove.append(k)
539-
540-
for k in to_remove:
541-
del self._views[k]
542-
543536
def add_view(self, view: View, message_id: Optional[int] = None) -> None:
544537
view._start_listening_from_store(self)
545538
if view.__discord_ui_modal__:
546539
self._modals[view.custom_id] = view # type: ignore
547540
return
548541

549-
self.__verify_integrity()
550-
542+
dispatch_info = self._views.setdefault(message_id, {})
551543
for item in view._children:
552544
if item.is_dispatchable():
553-
self._views[(item.type.value, message_id, item.custom_id)] = (view, item) # type: ignore
545+
dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore
554546

547+
view._cache_key = message_id
555548
if message_id is not None:
556549
self._synced_message_views[message_id] = view
557550

@@ -560,28 +553,62 @@ def remove_view(self, view: View) -> None:
560553
self._modals.pop(view.custom_id, None) # type: ignore
561554
return
562555

563-
for item in view._children:
564-
if item.is_dispatchable():
565-
self._views.pop((item.type.value, item.custom_id), None) # type: ignore
556+
dispatch_info = self._views.get(view._cache_key)
557+
if dispatch_info:
558+
for item in view._children:
559+
if item.is_dispatchable():
560+
dispatch_info.pop((item.type.value, item.custom_id), None) # type: ignore
561+
562+
if len(dispatch_info) == 0:
563+
self._views.pop(view._cache_key, None)
566564

567-
for key, value in self._synced_message_views.items():
568-
if value.id == view.id:
569-
del self._synced_message_views[key]
570-
break
565+
self._synced_message_views.pop(view._cache_key, None) # type: ignore
571566

572567
def dispatch_view(self, component_type: int, custom_id: str, interaction: Interaction) -> None:
573-
self.__verify_integrity()
574-
message_id: Optional[int] = interaction.message and interaction.message.id
575-
key = (component_type, message_id, custom_id)
576-
# Fallback to None message_id searches in case a persistent view
577-
# was added without an associated message_id
578-
value = self._views.get(key) or self._views.get((component_type, None, custom_id))
579-
if value is None:
568+
interaction_id: Optional[int] = None
569+
message_id: Optional[int] = None
570+
# Realistically, in a component based interaction the Interaction.message will never be None
571+
# However, this guard is just in case Discord screws up somehow
572+
msg = interaction.message
573+
if msg is not None:
574+
message_id = msg.id
575+
if msg.interaction:
576+
interaction_id = msg.interaction.id
577+
578+
key = (component_type, custom_id)
579+
580+
# The entity_id can either be message_id, interaction_id, or None in that priority order.
581+
item: Optional[Item[View]] = None
582+
if message_id is not None:
583+
item = self._views.get(message_id, {}).get(key)
584+
585+
if item is None and interaction_id is not None:
586+
try:
587+
items = self._views.pop(interaction_id)
588+
except KeyError:
589+
item = None
590+
else:
591+
item = items.get(key)
592+
# If we actually got the items, then these keys should probably be moved
593+
# to the proper message_id instead of the interaction_id as they are now.
594+
# An interaction_id is only used as a temporary stop gap for
595+
# InteractionResponse.send_message so multiple view instances do not
596+
# override each other.
597+
# NOTE: Fix this mess if /callback endpoint ever gets proper return types
598+
self._views.setdefault(message_id, {}).update(items)
599+
600+
if item is None:
601+
# Fallback to None message_id searches in case a persistent view
602+
# was added without an associated message_id
603+
item = self._views.get(None, {}).get(key)
604+
605+
# If 3 lookups failed at this point then just discard it
606+
if item is None:
580607
return
581608

582-
view, item = value
583609
item._refresh_state(interaction.data) # type: ignore
584-
view._dispatch_item(item, interaction)
610+
# Note, at this point the View is *not* None
611+
item.view._dispatch_item(item, interaction) # type: ignore
585612

586613
def dispatch_modal(
587614
self,
@@ -597,6 +624,10 @@ def dispatch_modal(
597624
modal._refresh(components)
598625
modal._dispatch_submit(interaction)
599626

627+
def remove_interaction_mapping(self, interaction_id: int) -> None:
628+
# This is called before re-adding the view
629+
self._views.pop(interaction_id, None)
630+
600631
def is_message_tracked(self, message_id: int) -> bool:
601632
return message_id in self._synced_message_views
602633

0 commit comments

Comments
 (0)