diff --git a/redbot/core/bot.py b/redbot/core/bot.py index e45e8278a..3bfafaf35 100644 --- a/redbot/core/bot.py +++ b/redbot/core/bot.py @@ -120,7 +120,7 @@ class Red( help__page_char_limit=1000, help__max_pages_in_guild=2, help__delete_delay=0, - help__use_menus=False, + help__use_menus=0, help__show_hidden=False, help__show_aliases=True, help__verify_checks=True, @@ -1045,6 +1045,16 @@ class Red( await self._schema_1_to_2() schema_version += 1 await self._config.schema_version.set(schema_version) + if schema_version == 2: + await self._schema_2_to_3() + schema_version += 1 + await self._config.schema_version.set(schema_version) + + async def _schema_2_to_3(self): + log.info("Migrating help menus to enum values") + old = await self._config.help__use_menus() + if old is not None: + await self._config.help__use_menus.set(int(old)) async def _schema_1_to_2(self): """ diff --git a/redbot/core/commands/help.py b/redbot/core/commands/help.py index ad69e1315..5fe8d6b47 100644 --- a/redbot/core/commands/help.py +++ b/redbot/core/commands/help.py @@ -31,6 +31,7 @@ import abc import asyncio from collections import namedtuple from dataclasses import dataclass, asdict as dc_asdict +from enum import Enum from typing import Union, List, AsyncIterator, Iterable, cast import discord @@ -39,6 +40,7 @@ from discord.ext import commands as dpy_commands from . import commands from .context import Context from ..i18n import Translator +from ..utils.views import SimpleMenu from ..utils import can_user_react_in, menus from ..utils.mod import mass_purge from ..utils._internal_utils import fuzzy_command_search, format_fuzzy_results @@ -65,6 +67,14 @@ EmbedField = namedtuple("EmbedField", "name value inline") EMPTY_STRING = "\N{ZERO WIDTH SPACE}" +class HelpMenuSetting(Enum): + disabled = 0 + reactions = 1 + buttons = 2 + select = 3 + selectonly = 4 + + @dataclass(frozen=True) class HelpSettings: """ @@ -78,7 +88,7 @@ class HelpSettings: page_char_limit: int = 1000 max_pages_in_guild: int = 2 - use_menus: bool = False + use_menus: HelpMenuSetting = HelpMenuSetting(0) show_hidden: bool = False show_aliases: bool = True verify_checks: bool = True @@ -103,7 +113,8 @@ class HelpSettings: Get the HelpSettings for the current context """ settings = await context.bot._config.help.all() - return cls(**settings) + menus = settings.pop("use_menus", 0) + return cls(**settings, use_menus=HelpMenuSetting(menus)) @property def pretty(self): @@ -129,6 +140,14 @@ class HelpSettings: tagline_info = "" data["tagline_info"] = tagline_info + menus_str = { + HelpMenuSetting.disabled: _("No"), + HelpMenuSetting.reactions: _("Yes, reactions"), + HelpMenuSetting.buttons: _("Yes, buttons"), + HelpMenuSetting.select: _("Yes, buttons with select menu"), + HelpMenuSetting.selectonly: _("Yes, select menu only"), + } + data["use_menus"] = menus_str[self.use_menus] return _( "Maximum characters per page: {page_char_limit}" @@ -813,7 +832,31 @@ class RedHelpFormatter(HelpFormatterABC): """ Sends pages based on settings. """ - if not (can_user_react_in(ctx.me, ctx.channel) and help_settings.use_menus): + if help_settings.use_menus.value >= HelpMenuSetting.buttons.value: + use_select = help_settings.use_menus.value == 3 + select_only = help_settings.use_menus.value == 4 + await SimpleMenu( + pages, + timeout=help_settings.react_timeout, + use_select_menu=use_select, + use_select_only=select_only, + ).start(ctx) + + elif ( + can_user_react_in(ctx.me, ctx.channel) + and help_settings.use_menus is HelpMenuSetting.reactions + ): + # Specifically ensuring the menu's message is sent prior to returning + m = await (ctx.send(embed=pages[0]) if embed else ctx.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. + asyncio.create_task( + menus.menu(ctx, pages, c, 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()) + + else: max_pages_in_guild = help_settings.max_pages_in_guild use_DMs = len(pages) > max_pages_in_guild destination = ctx.author if use_DMs else ctx.channel @@ -855,16 +898,6 @@ class RedHelpFormatter(HelpFormatterABC): await mass_purge(messages, channel) asyncio.create_task(_delete_delay_help(destination, messages, delete_delay)) - else: - # Specifically ensuring the menu's message is sent prior to returning - m = await (ctx.send(embed=pages[0]) if embed else ctx.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. - asyncio.create_task( - menus.menu(ctx, pages, c, 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()) @commands.command(name="help", hidden=True, i18n=_) diff --git a/redbot/core/core_commands.py b/redbot/core/core_commands.py index 875ccb887..263d680d3 100644 --- a/redbot/core/core_commands.py +++ b/redbot/core/core_commands.py @@ -22,7 +22,18 @@ from redbot.core.utils.menus import menu from redbot.core.utils.views import SetApiView from redbot.core.commands import GuildConverter, RawUserIdConverter from string import ascii_letters, digits -from typing import TYPE_CHECKING, Union, Tuple, List, Optional, Iterable, Sequence, Dict, Set +from typing import ( + TYPE_CHECKING, + Union, + Tuple, + List, + Optional, + Iterable, + Sequence, + Dict, + Set, + Literal, +) import aiohttp import discord @@ -54,6 +65,7 @@ from .utils.chat_formatting import ( ) from .commands import CommandConverter, CogConverter from .commands.requires import PrivilegeLevel +from .commands.help import HelpMenuSetting _entities = { "*": "*", @@ -3680,30 +3692,47 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): ) @helpset.command(name="usemenus") - async def helpset_usemenus(self, ctx: commands.Context, use_menus: bool = None): + async def helpset_usemenus( + self, + ctx: commands.Context, + use_menus: Literal["buttons", "reactions", "select", "selectonly", "disable"], + ): """ Allows the help command to be sent as a paginated menu instead of separate messages. - When enabled, `[p]help` will only show one page at a time and will use reactions to navigate between pages. - - This defaults to False. - Using this without a setting will toggle. + When "reactions", "buttons", "select", or "selectonly" is passed, + `[p]help` will only show one page at a time + and will use the associated control scheme to navigate between pages. **Examples:** - - `[p]helpset usemenus True` - Enables using menus. - - `[p]helpset usemenus` - Toggles the value. + - `[p]helpset usemenus reactions` - Enables using reaction menus. + - `[p]helpset usemenus buttons` - Enables using button menus. + - `[p]helpset usemenus select` - Enables buttons with a select menu. + - `[p]helpset usemenus selectonly` - Enables a select menu only on help. + - `[p]helpset usemenus disable` - Disables help menus. **Arguments:** - - `[use_menus]` - Whether to use menus. Leave blank to toggle. + - `<"buttons"|"reactions"|"select"|"selectonly"|"disable">` - Whether to use `buttons`, + `reactions`, `select`, `selectonly`, or no menus. """ - if use_menus is None: - use_menus = not await ctx.bot._config.help.use_menus() - await ctx.bot._config.help.use_menus.set(use_menus) - if use_menus: - await ctx.send(_("Help will use menus.")) - else: - await ctx.send(_("Help will not use menus.")) + if use_menus == "selectonly": + msg = _("Help will use the select menu only.") + await ctx.bot._config.help.use_menus.set(4) + if use_menus == "select": + msg = _("Help will use button menus and add a select menu.") + await ctx.bot._config.help.use_menus.set(3) + if use_menus == "buttons": + msg = _("Help will use button menus.") + await ctx.bot._config.help.use_menus.set(2) + if use_menus == "reactions": + msg = _("Help will use reaction menus.") + await ctx.bot._config.help.use_menus.set(1) + if use_menus == "disabled": + msg = _("Help will not use menus.") + await ctx.bot._config.help.use_menus.set(0) + + await ctx.send(msg) @helpset.command(name="showhidden") async def helpset_showhidden(self, ctx: commands.Context, show_hidden: bool = None): diff --git a/redbot/core/utils/views.py b/redbot/core/utils/views.py index 45db6c51b..eee148be0 100644 --- a/redbot/core/utils/views.py +++ b/redbot/core/utils/views.py @@ -3,12 +3,270 @@ from __future__ import annotations import discord from discord.ext.commands import BadArgument -from typing import List, Dict, Union, Optional -from redbot.core.commands.converter import get_dict_converter +from typing import TYPE_CHECKING, Any, List, Optional, Union, Dict from redbot.core.i18n import Translator +from redbot.vendored.discord.ext import menus +from redbot.core.commands.converter import get_dict_converter + + +if TYPE_CHECKING: + from redbot.core.commands import Context _ = Translator("UtilsViews", __file__) +_ACCEPTABLE_PAGE_TYPES = Union[Dict[str, Union[str, discord.Embed]], discord.Embed, str] + + +class _SimplePageSource(menus.ListPageSource): + def __init__(self, items: List[_ACCEPTABLE_PAGE_TYPES]): + super().__init__(items, per_page=1) + + async def format_page( + self, view: discord.ui.View, page: _ACCEPTABLE_PAGE_TYPES + ) -> Union[str, discord.Embed]: + return page + + +class _SelectMenu(discord.ui.Select): + def __init__(self, options: List[discord.SelectOption]): + super().__init__( + placeholder=_("Select a Page"), min_values=1, max_values=1, options=options + ) + + async def callback(self, interaction: discord.Interaction): + index = int(self.values[0]) + self.view.current_page = index + kwargs = await self.view.get_page(self.view.current_page) + await interaction.response.edit_message(**kwargs) + + +class _NavigateButton(discord.ui.Button): + def __init__( + self, style: discord.ButtonStyle, emoji: Union[str, discord.PartialEmoji], direction: int + ): + super().__init__(style=style, emoji=emoji) + self.direction = direction + + async def callback(self, interaction: discord.Interaction): + if self.direction == 0: + self.view.current_page = 0 + elif self.direction == self.view.source.get_max_pages(): + self.view.current_page = self.view.source.get_max_pages() - 1 + else: + self.view.current_page += self.direction + kwargs = await self.view.get_page(self.view.current_page) + await interaction.response.edit_message(**kwargs) + + +class _StopButton(discord.ui.Button): + def __init__(self, style: discord.ButtonStyle, emoji: Union[str, discord.PartialEmoji]): + super().__init__(style=style, emoji=emoji) + + async def callback(self, interaction: discord.Interaction): + self.view.stop() + if interaction.message.flags.ephemeral: + await interaction.response.edit_message(view=None) + return + await interaction.message.delete() + + +class SimpleMenu(discord.ui.View): + """ + A simple Button menu + + Parameters + ---------- + pages: `list` of `str`, `discord.Embed`, or `dict`. + The pages of the menu. + if the page is a `dict` its keys must be valid messageable args. + e,g. "content", "embed", etc. + page_start: int + The page to start the menu at. + timeout: float + The time (in seconds) to wait for a reaction + defaults to 180 seconds. + delete_after_timeout: bool + Whether or not to delete the message after + the timeout has expired. + Defaults to False. + disable_after_timeout: bool + Whether to disable all components on the + menu after timeout has expired. By default + the view is removed from the message on timeout. + Defaults to False. + use_select_menu: bool + Whether or not to include a select menu + to jump specifically between pages. + Defaults to False. + use_select_only: bool + Whether the menu will only display the select + menu for paginating instead of the buttons. + The stop button will remain but is positioned + under the select menu in this instance. + Defaults to False. + + Examples + -------- + You can provide a list of strings:: + + from redbot.core.utils.views import SimpleMenu + + pages = ["Hello", "Hi", "Bonjour", "Salut"] + await SimpleMenu(pages).start(ctx) + + You can provide a list of dicts:: + + from redbot.core.utils.views import SimpleMenu + pages = [{"content": "My content", "embed": discord.Embed(description="hello")}] + await SimpleMenu(pages).start(ctx) + + """ + + def __init__( + self, + pages: List[_ACCEPTABLE_PAGE_TYPES], + timeout: float = 180.0, + page_start: int = 0, + delete_after_timeout: bool = False, + disable_after_timeout: bool = False, + use_select_menu: bool = False, + use_select_only: bool = False, + ) -> None: + super().__init__( + timeout=timeout, + ) + self.author: Optional[discord.abc.User] = None + self.message: Optional[discord.Message] = None + self._source = _SimplePageSource(items=pages) + self.ctx: Optional[Context] = None + self.current_page = page_start + self.delete_after_timeout = delete_after_timeout + self.disable_after_timeout = disable_after_timeout + self.use_select_menu = use_select_menu or use_select_only + self.use_select_only = use_select_only + + self.forward_button = _NavigateButton( + discord.ButtonStyle.grey, + "\N{BLACK RIGHT-POINTING TRIANGLE}\N{VARIATION SELECTOR-16}", + direction=1, + ) + self.backward_button = _NavigateButton( + discord.ButtonStyle.grey, + "\N{BLACK LEFT-POINTING TRIANGLE}\N{VARIATION SELECTOR-16}", + direction=-1, + ) + self.first_button = _NavigateButton( + discord.ButtonStyle.grey, + "\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}\N{VARIATION SELECTOR-16}", + direction=0, + ) + self.last_button = _NavigateButton( + discord.ButtonStyle.grey, + "\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}\N{VARIATION SELECTOR-16}", + direction=self.source.get_max_pages(), + ) + self.select_options = [ + discord.SelectOption(label=_("Page {num}").format(num=num + 1), value=num) + for num, x in enumerate(pages) + ] + self.stop_button = _StopButton( + discord.ButtonStyle.red, "\N{HEAVY MULTIPLICATION X}\N{VARIATION SELECTOR-16}" + ) + self.select_menu = self._get_select_menu() + self.add_item(self.stop_button) + if self.source.is_paginating() and not self.use_select_only: + self.add_item(self.first_button) + self.add_item(self.backward_button) + self.add_item(self.forward_button) + self.add_item(self.last_button) + if self.use_select_menu and self.source.is_paginating(): + if self.use_select_only: + self.remove_item(self.stop_button) + self.add_item(self.select_menu) + self.add_item(self.stop_button) + else: + self.add_item(self.select_menu) + + @property + def source(self): + return self._source + + async def on_timeout(self): + if self.delete_after_timeout and not self.message.flags.ephemeral: + await self.message.delete() + elif self.disable_after_timeout: + for child in self.children: + child.disabled = True + await self.message.edit(view=self) + else: + await self.message.edit(view=None) + + def _get_select_menu(self): + # handles modifying the select menu if more than 25 pages are provided + # this will show the previous 12 and next 13 pages in the select menu + # based on the currently displayed page. Once you reach close to the max + # pages it will display the last 25 pages. + if len(self.select_options) > 25: + minus_diff = None + plus_diff = 25 + if 12 < self.current_page < len(self.select_options) - 25: + minus_diff = self.current_page - 12 + plus_diff = self.current_page + 13 + elif self.current_page >= len(self.select_options) - 25: + minus_diff = len(self.select_options) - 25 + plus_diff = None + options = self.select_options[minus_diff:plus_diff] + else: + options = self.select_options[:25] + return _SelectMenu(options) + + async def start(self, ctx: Context, *, ephemeral: bool = False): + """ + Used to start the menu displaying the first page requested. + + Parameters + ---------- + ctx: `commands.Context` + The context to start the menu in. + ephemeral: `bool` + Send the message ephemerally. This only works + if the context is from a slash command interaction. + """ + self.author = ctx.author + self.ctx = ctx + kwargs = await self.get_page(self.current_page) + self.message = await ctx.send(**kwargs, ephemeral=ephemeral) + + async def get_page(self, page_num: int) -> Dict[str, Optional[Any]]: + try: + page = await self.source.get_page(page_num) + except IndexError: + self.current_page = 0 + page = await self.source.get_page(self.current_page) + value = await self.source.format_page(self, page) + if self.use_select_menu and len(self.select_options) > 25 and self.source.is_paginating(): + self.remove_item(self.select_menu) + self.select_menu = self._get_select_menu() + self.add_item(self.select_menu) + ret: Dict[str, Optional[Any]] = {"view": self} + if isinstance(value, dict): + ret.update(value) + elif isinstance(value, str): + ret.update({"content": value, "embed": None}) + elif isinstance(value, discord.Embed): + ret.update({"embed": value, "content": None}) + return ret + + async def interaction_check(self, interaction: discord.Interaction): + """Ensure only the author is allowed to interact with the menu.""" + allowed_ids = (getattr(self.author, "id", None),) + if interaction.user.id not in allowed_ids: + await interaction.response.send_message( + content=_("You are not authorized to interact with this."), ephemeral=True + ) + return False + return True + class SetApiModal(discord.ui.Modal): """