diff --git a/redbot/core/commands/help.py b/redbot/core/commands/help.py index 111d6d302dc..a86b16bb5ab 100644 --- a/redbot/core/commands/help.py +++ b/redbot/core/commands/help.py @@ -878,14 +878,10 @@ async def send_pages( m = await (destination.send(embed=pages[0]) if embed else destination.send(pages[0])) c = menus.DEFAULT_CONTROLS if len(pages) > 1 else {"\N{CROSS MARK}": menus.close_menu} # Allow other things to happen during menu timeout/interaction. - if use_DMs: - menu_ctx = await ctx.bot.get_context(m) - # Monkeypatch so help listens for reactions from the original author, not the bot - menu_ctx.author = ctx.author - else: - menu_ctx = ctx asyncio.create_task( - menus.menu(menu_ctx, pages, c, message=m, timeout=help_settings.react_timeout) + menus.menu( + ctx, pages, c, user=ctx.author, message=m, timeout=help_settings.react_timeout + ) ) # menu needs reactions added manually since we fed it a message menus.start_adding_reactions(m, c.keys()) diff --git a/redbot/core/utils/menus.py b/redbot/core/utils/menus.py index ee63a70045e..d3a52dde78e 100644 --- a/redbot/core/utils/menus.py +++ b/redbot/core/utils/menus.py @@ -49,48 +49,126 @@ async def callback(self, interaction: discord.Interaction): if self.emoji.is_unicode_emoji() else (ctx.bot.get_emoji(self.emoji.id) or self.emoji) ) - await self.func(ctx, pages, controls, message, page, timeout, emoji) + user = self.view.author if not self.view._fallback_author_to_ctx else None + if user is not None: + await self.func(ctx, pages, controls, message, page, timeout, emoji, user=user) + else: + await self.func(ctx, pages, controls, message, page, timeout, emoji) async def menu( ctx: commands.Context, pages: _PageList, controls: Optional[Mapping[str, _ControlCallable]] = None, - message: discord.Message = None, + message: Optional[discord.Message] = None, page: int = 0, timeout: float = 30.0, + *, + user: Optional[discord.User] = None, ) -> _T: """ An emoji-based menu - .. note:: All pages should be of the same type + All functions for handling what a particular emoji does + should be coroutines (i.e. :code:`async def`). Additionally, + they must take all of the parameters of this function, in + addition to a string representing the emoji reacted with. + This parameter should be the 7th one, and none of the + parameters in the handling functions are optional. + + .. warning:: + + The ``user`` parameter is considered `provisional `. + If no issues arise, we plan on including it under developer guarantees + in the first release made after 2024-05-18. + + .. warning:: + + If you're using the ``user`` param, you need to pass it + as a keyword-only argument, and set :obj:`None` as the + default in your function. + + Examples + -------- + + Simple menu using default controls:: + + from redbot.core.utils.menus import menu + + pages = ["Hello", "Hi", "Bonjour", "Salut"] + await menu(ctx, pages) + + Menu with a custom control performing an action (deleting an item from pages list):: + + from redbot.core.utils import menus + + items = ["Apple", "Banana", "Cucumber", "Dragonfruit"] + + def generate_pages(): + return [f"{fruit} is an awesome fruit!" for fruit in items] + + async def delete_item_action(ctx, pages, controls, message, page, timeout, emoji): + fruit = items.pop(page) # lookup and remove corresponding fruit name + await ctx.send(f"I guess you don't like {fruit}, huh? Deleting...") + pages = generate_pages() + if not pages: + return await menus.close_menu(ctx, pages, controls, message, page, timeout) + page = min(page, len(pages) - 1) + return await menus.menu(ctx, pages, controls, message, page, timeout) + + pages = generate_pages() + controls = {**menus.DEFAULT_CONTROLS, "\\N{NO ENTRY SIGN}": delete_item_action} + await menus.menu(ctx, pages, controls) - .. note:: All functions for handling what a particular emoji does - should be coroutines (i.e. :code:`async def`). Additionally, - they must take all of the parameters of this function, in - addition to a string representing the emoji reacted with. - This parameter should be the last one, and none of the - parameters in the handling functions are optional + Menu with custom controls that output a result (confirmation prompt):: + + from redbot.core.utils.menus import menu + + async def control_yes(*args, **kwargs): + return True + + async def control_no(*args, **kwargs): + return False + + msg = "Do you wish to continue?" + controls = { + "\\N{WHITE HEAVY CHECK MARK}": control_yes, + "\\N{CROSS MARK}": control_no, + } + reply = await menu(ctx, [msg], controls) + if reply: + await ctx.send("Continuing...") + else: + await ctx.send("Okay, I'm not going to perform the requested action.") Parameters ---------- ctx: commands.Context The command context - pages: `list` of `str` or `discord.Embed` + pages: Union[List[str], List[discord.Embed]] The pages of the menu. + All pages need to be of the same type (either `str` or `discord.Embed`). controls: Optional[Mapping[str, Callable]] A mapping of emoji to the function which handles the action for the emoji. The signature of the function should be the same as of this function and should additionally accept an ``emoji`` parameter of type `str`. If not passed, `DEFAULT_CONTROLS` is used *or* only a close menu control is shown when ``pages`` is of length 1. - message: discord.Message + message: Optional[discord.Message] The message representing the menu. Usually :code:`None` when first opening the menu page: int The current page number of the menu timeout: float The time (in seconds) to wait for a reaction + user: Optional[discord.User] + The user allowed to interact with the menu. Defaults to ``ctx.author``. + + .. warning:: + + This parameter is `provisional `. + If no issues arise, we plan on including it under developer guarantees + in the first release made after 2024-05-18. Raises ------ @@ -136,7 +214,7 @@ async def menu( # internally we already include the emojis we expect. if controls == DEFAULT_CONTROLS: view = SimpleMenu(pages, timeout=timeout) - await view.start(ctx) + await view.start(ctx, user=user) await view.wait() return else: @@ -169,7 +247,7 @@ async def menu( view.remove_item(view.stop_button) for emoji, func in to_add.items(): view.add_item(_GenericButton(emoji, func)) - await view.start(ctx) + await view.start(ctx, user=user) _active_menus[view.message.id] = view await view.wait() del _active_menus[view.message.id] @@ -194,7 +272,9 @@ async def menu( return try: - predicates = ReactionPredicate.with_emojis(tuple(controls.keys()), message, ctx.author) + predicates = ReactionPredicate.with_emojis( + tuple(controls.keys()), message, user or ctx.author + ) tasks = [ asyncio.create_task(ctx.bot.wait_for("reaction_add", check=predicates)), asyncio.create_task(ctx.bot.wait_for("reaction_remove", check=predicates)), @@ -230,9 +310,14 @@ async def menu( except discord.NotFound: return else: - return await controls[react.emoji]( - ctx, pages, controls, message, page, timeout, react.emoji - ) + if user is not None: + return await controls[react.emoji]( + ctx, pages, controls, message, page, timeout, react.emoji, user=user + ) + else: + return await controls[react.emoji]( + ctx, pages, controls, message, page, timeout, react.emoji + ) async def next_page( @@ -243,6 +328,8 @@ async def next_page( page: int, timeout: float, emoji: str, + *, + user: Optional[discord.User] = None, ) -> _T: """ Function for showing next page which is suitable @@ -252,7 +339,12 @@ async def next_page( page = 0 # Loop around to the first item else: page = page + 1 - return await menu(ctx, pages, controls, message=message, page=page, timeout=timeout) + if user is not None: + return await menu( + ctx, pages, controls, message=message, page=page, timeout=timeout, user=user + ) + else: + return await menu(ctx, pages, controls, message=message, page=page, timeout=timeout) async def prev_page( @@ -263,6 +355,8 @@ async def prev_page( page: int, timeout: float, emoji: str, + *, + user: Optional[discord.User] = None, ) -> _T: """ Function for showing previous page which is suitable @@ -272,7 +366,12 @@ async def prev_page( page = len(pages) - 1 # Loop around to the last item else: page = page - 1 - return await menu(ctx, pages, controls, message=message, page=page, timeout=timeout) + if user is not None: + return await menu( + ctx, pages, controls, message=message, page=page, timeout=timeout, user=user + ) + else: + return await menu(ctx, pages, controls, message=message, page=page, timeout=timeout) async def close_menu( @@ -283,6 +382,8 @@ async def close_menu( page: int, timeout: float, emoji: str, + *, + user: Optional[discord.User] = None, ) -> None: """ Function for closing (deleting) menu which is suitable diff --git a/redbot/core/utils/views.py b/redbot/core/utils/views.py index f6d2fea48a1..51fb9efa9d6 100644 --- a/redbot/core/utils/views.py +++ b/redbot/core/utils/views.py @@ -136,6 +136,7 @@ def __init__( super().__init__( timeout=timeout, ) + self._fallback_author_to_ctx = True self.author: Optional[discord.abc.User] = None self.message: Optional[discord.Message] = None self._source = _SimplePageSource(items=pages) @@ -192,6 +193,19 @@ def __init__( def source(self): return self._source + @property + def author(self) -> Optional[discord.abc.User]: + if self._author is not None: + return self._author + if self._fallback_author_to_ctx: + return getattr(self.ctx, "author", None) + return None + + @author.setter + def author(self, value: Optional[discord.abc.User]) -> None: + self._fallback_author_to_ctx = False + self._author = value + async def on_timeout(self): try: if self.delete_after_timeout and not self.message.flags.ephemeral: @@ -225,19 +239,38 @@ def _get_select_menu(self): options = self.select_options[:25] return _SelectMenu(options) - async def start(self, ctx: Context, *, ephemeral: bool = False): + async def start( + self, ctx: Context, *, user: Optional[discord.abc.User] = None, ephemeral: bool = False + ): """ Used to start the menu displaying the first page requested. + .. warning:: + + The ``user`` parameter is considered `provisional `. + If no issues arise, we plan on including it under developer guarantees + in the first release made after 2024-05-18. + Parameters ---------- ctx: `commands.Context` The context to start the menu in. + user: discord.User + The user allowed to interact with the menu. + If this is ``None``, ``ctx.author`` will be able to interact with the menu. + + .. warning:: + + This parameter is `provisional `. + If no issues arise, we plan on including it under developer guarantees + in the first release made after 2024-05-18. ephemeral: `bool` Send the message ephemerally. This only works if the context is from a slash command interaction. """ - self.author = ctx.author + self._fallback_author_to_ctx = True + if user is not None: + self.author = user self.ctx = ctx kwargs = await self.get_page(self.current_page) self.message = await ctx.send(**kwargs, ephemeral=ephemeral)