diff --git a/interactions/api/events/processors/reaction_events.py b/interactions/api/events/processors/reaction_events.py index 2f1de264f..7d234341a 100644 --- a/interactions/api/events/processors/reaction_events.py +++ b/interactions/api/events/processors/reaction_events.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING import interactions.api.events as events -from interactions.models import PartialEmoji, Reaction +from interactions.models import PartialEmoji, Reaction, Message, Permissions from ._template import EventMixinTemplate, Processor @@ -12,6 +12,29 @@ class ReactionEvents(EventMixinTemplate): + async def _check_message_fetch_permissions(self, channel_id: str, guild_id: str | None) -> bool: + """ + Check if the bot has permissions to fetch a message in the given channel. + + Args: + channel_id: The ID of the channel to check + guild_id: The ID of the guild, if any + + Returns: + bool: True if the bot has permission to fetch messages, False otherwise + + """ + if not guild_id: # DMs always have permission + return True + + channel = await self.cache.fetch_channel(channel_id) + if not channel: + return False + + bot_member = channel.guild.me + ctx_perms = channel.permissions_for(bot_member) + return Permissions.READ_MESSAGE_HISTORY in ctx_perms + async def _handle_message_reaction_change(self, event: "RawGatewayEvent", add: bool) -> None: if member := event.data.get("member"): author = self.cache.place_member_data(event.data.get("guild_id"), member) @@ -53,11 +76,27 @@ async def _handle_message_reaction_change(self, event: "RawGatewayEvent", add: b message.reactions.append(reaction) else: - message = await self.cache.fetch_message(event.data.get("channel_id"), event.data.get("message_id")) - for r in message.reactions: - if r.emoji == emoji: - reaction = r - break + guild_id = event.data.get("guild_id") + channel_id = event.data.get("channel_id") + + if await self._check_message_fetch_permissions(channel_id, guild_id): + message = await self.cache.fetch_message(channel_id, event.data.get("message_id")) + for r in message.reactions: + if r.emoji == emoji: + reaction = r + break + + if not message: # otherwise construct skeleton message with no reactions + message = Message.from_dict( + { + "id": event.data.get("message_id"), + "channel_id": channel_id, + "guild_id": guild_id, + "reactions": [], + }, + self, + ) + if add: self.dispatch(events.MessageReactionAdd(message=message, emoji=emoji, author=author, reaction=reaction)) else: diff --git a/tests/test_bot.py b/tests/test_bot.py index 33267eaf5..7aa66cd8c 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -127,6 +127,39 @@ def ensure_attributes(target_object) -> None: getattr(target_object, attr) +@pytest.mark.asyncio +async def test_reaction_events(bot: Client, guild: Guild) -> None: + """ + Tests reaction event handling on an uncached message. + + Requires manual setup: + 1. Set TARGET_CHANNEL_ID environment variable to a valid channel ID. + 2. A user must add a reaction to the test message within 60 seconds. + """ + # Skip test if target channel not provided + target_channel_id = os.environ.get("BOT_TEST_CHANNEL_ID") + if not target_channel_id: + pytest.skip("Set TARGET_CHANNEL_ID to run this test") + + # Get channel and post test message + channel = await bot.fetch_channel(target_channel_id) + test_msg = await channel.send("Reaction Event Test - React with ✅ within 60 seconds") + + try: + # simulate uncached state + bot.cache.delete_message(message_id=test_msg.id, channel_id=test_msg.channel.id) + + # wait for user to react with checkmark + reaction_event = await bot.wait_for( + "message_reaction_add", timeout=60, checks=lambda e: e.message.id == test_msg.id and str(e.emoji) == "✅" + ) + + assert reaction_event.message.id == test_msg.id + assert reaction_event.emoji.name == "✅" + finally: + await test_msg.delete() + + @pytest.mark.asyncio async def test_channels(bot: Client, guild: Guild) -> None: channels = [