Add user param to menu() (#4913)

Co-authored-by: jack1142 <6032823+jack1142@users.noreply.github.com>
Co-authored-by: Jakub Kuczys <me@jacken.men>
This commit is contained in:
Auguste Charpentier 2024-03-18 01:35:53 +01:00 committed by GitHub
parent 4c7a691ec9
commit 8c2976504a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 158 additions and 28 deletions

View File

@ -878,14 +878,10 @@ class RedHelpFormatter(HelpFormatterABC):
m = await (destination.send(embed=pages[0]) if embed else destination.send(pages[0])) 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} c = menus.DEFAULT_CONTROLS if len(pages) > 1 else {"\N{CROSS MARK}": menus.close_menu}
# Allow other things to happen during menu timeout/interaction. # 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( 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 # menu needs reactions added manually since we fed it a message
menus.start_adding_reactions(m, c.keys()) menus.start_adding_reactions(m, c.keys())

View File

@ -49,48 +49,126 @@ class _GenericButton(discord.ui.Button):
if self.emoji.is_unicode_emoji() if self.emoji.is_unicode_emoji()
else (ctx.bot.get_emoji(self.emoji.id) or self.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( async def menu(
ctx: commands.Context, ctx: commands.Context,
pages: _PageList, pages: _PageList,
controls: Optional[Mapping[str, _ControlCallable]] = None, controls: Optional[Mapping[str, _ControlCallable]] = None,
message: discord.Message = None, message: Optional[discord.Message] = None,
page: int = 0, page: int = 0,
timeout: float = 30.0, timeout: float = 30.0,
*,
user: Optional[discord.User] = None,
) -> _T: ) -> _T:
""" """
An emoji-based menu 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.
.. note:: All functions for handling what a particular emoji does .. warning::
should be coroutines (i.e. :code:`async def`). Additionally,
they must take all of the parameters of this function, in The ``user`` parameter is considered `provisional <developer-guarantees-exclusions>`.
addition to a string representing the emoji reacted with. If no issues arise, we plan on including it under developer guarantees
This parameter should be the last one, and none of the in the first release made after 2024-05-18.
parameters in the handling functions are optional
.. 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)
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 Parameters
---------- ----------
ctx: commands.Context ctx: commands.Context
The command context The command context
pages: `list` of `str` or `discord.Embed` pages: Union[List[str], List[discord.Embed]]
The pages of the menu. The pages of the menu.
All pages need to be of the same type (either `str` or `discord.Embed`).
controls: Optional[Mapping[str, Callable]] controls: Optional[Mapping[str, Callable]]
A mapping of emoji to the function which handles the action for the 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 emoji. The signature of the function should be the same as of this function
and should additionally accept an ``emoji`` parameter of type `str`. and should additionally accept an ``emoji`` parameter of type `str`.
If not passed, `DEFAULT_CONTROLS` is used *or* If not passed, `DEFAULT_CONTROLS` is used *or*
only a close menu control is shown when ``pages`` is of length 1. 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 message representing the menu. Usually :code:`None` when first opening
the menu the menu
page: int page: int
The current page number of the menu The current page number of the menu
timeout: float timeout: float
The time (in seconds) to wait for a reaction 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 <developer-guarantees-exclusions>`.
If no issues arise, we plan on including it under developer guarantees
in the first release made after 2024-05-18.
Raises Raises
------ ------
@ -136,7 +214,7 @@ async def menu(
# internally we already include the emojis we expect. # internally we already include the emojis we expect.
if controls == DEFAULT_CONTROLS: if controls == DEFAULT_CONTROLS:
view = SimpleMenu(pages, timeout=timeout) view = SimpleMenu(pages, timeout=timeout)
await view.start(ctx) await view.start(ctx, user=user)
await view.wait() await view.wait()
return return
else: else:
@ -169,7 +247,7 @@ async def menu(
view.remove_item(view.stop_button) view.remove_item(view.stop_button)
for emoji, func in to_add.items(): for emoji, func in to_add.items():
view.add_item(_GenericButton(emoji, func)) view.add_item(_GenericButton(emoji, func))
await view.start(ctx) await view.start(ctx, user=user)
_active_menus[view.message.id] = view _active_menus[view.message.id] = view
await view.wait() await view.wait()
del _active_menus[view.message.id] del _active_menus[view.message.id]
@ -194,7 +272,9 @@ async def menu(
return return
try: try:
predicates = ReactionPredicate.with_emojis(tuple(controls.keys()), message, ctx.author) predicates = ReactionPredicate.with_emojis(
tuple(controls.keys()), message, user or ctx.author
)
tasks = [ tasks = [
asyncio.create_task(ctx.bot.wait_for("reaction_add", check=predicates)), asyncio.create_task(ctx.bot.wait_for("reaction_add", check=predicates)),
asyncio.create_task(ctx.bot.wait_for("reaction_remove", check=predicates)), asyncio.create_task(ctx.bot.wait_for("reaction_remove", check=predicates)),
@ -230,9 +310,14 @@ async def menu(
except discord.NotFound: except discord.NotFound:
return return
else: else:
return await controls[react.emoji]( if user is not None:
ctx, pages, controls, message, page, timeout, react.emoji 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( async def next_page(
@ -243,6 +328,8 @@ async def next_page(
page: int, page: int,
timeout: float, timeout: float,
emoji: str, emoji: str,
*,
user: Optional[discord.User] = None,
) -> _T: ) -> _T:
""" """
Function for showing next page which is suitable Function for showing next page which is suitable
@ -252,7 +339,12 @@ async def next_page(
page = 0 # Loop around to the first item page = 0 # Loop around to the first item
else: else:
page = page + 1 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( async def prev_page(
@ -263,6 +355,8 @@ async def prev_page(
page: int, page: int,
timeout: float, timeout: float,
emoji: str, emoji: str,
*,
user: Optional[discord.User] = None,
) -> _T: ) -> _T:
""" """
Function for showing previous page which is suitable 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 page = len(pages) - 1 # Loop around to the last item
else: else:
page = page - 1 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( async def close_menu(
@ -283,6 +382,8 @@ async def close_menu(
page: int, page: int,
timeout: float, timeout: float,
emoji: str, emoji: str,
*,
user: Optional[discord.User] = None,
) -> None: ) -> None:
""" """
Function for closing (deleting) menu which is suitable Function for closing (deleting) menu which is suitable

View File

@ -136,6 +136,7 @@ class SimpleMenu(discord.ui.View):
super().__init__( super().__init__(
timeout=timeout, timeout=timeout,
) )
self._fallback_author_to_ctx = True
self.author: Optional[discord.abc.User] = None self.author: Optional[discord.abc.User] = None
self.message: Optional[discord.Message] = None self.message: Optional[discord.Message] = None
self._source = _SimplePageSource(items=pages) self._source = _SimplePageSource(items=pages)
@ -192,6 +193,19 @@ class SimpleMenu(discord.ui.View):
def source(self): def source(self):
return self._source 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): async def on_timeout(self):
try: try:
if self.delete_after_timeout and not self.message.flags.ephemeral: if self.delete_after_timeout and not self.message.flags.ephemeral:
@ -225,19 +239,38 @@ class SimpleMenu(discord.ui.View):
options = self.select_options[:25] options = self.select_options[:25]
return _SelectMenu(options) 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. Used to start the menu displaying the first page requested.
.. warning::
The ``user`` parameter is considered `provisional <developer-guarantees-exclusions>`.
If no issues arise, we plan on including it under developer guarantees
in the first release made after 2024-05-18.
Parameters Parameters
---------- ----------
ctx: `commands.Context` ctx: `commands.Context`
The context to start the menu in. 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 <developer-guarantees-exclusions>`.
If no issues arise, we plan on including it under developer guarantees
in the first release made after 2024-05-18.
ephemeral: `bool` ephemeral: `bool`
Send the message ephemerally. This only works Send the message ephemerally. This only works
if the context is from a slash command interaction. 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 self.ctx = ctx
kwargs = await self.get_page(self.current_page) kwargs = await self.get_page(self.current_page)
self.message = await ctx.send(**kwargs, ephemeral=ephemeral) self.message = await ctx.send(**kwargs, ephemeral=ephemeral)