diff --git a/redbot/core/bot.py b/redbot/core/bot.py index 40c42896c..02ebd1ec4 100644 --- a/redbot/core/bot.py +++ b/redbot/core/bot.py @@ -52,9 +52,11 @@ from .settings_caches import ( DisabledCogCache, I18nManager, ) +from .utils.predicates import MessagePredicate from .rpc import RPCMixin from .tree import RedTree from .utils import can_user_send_messages_in, common_filters, AsyncIter +from .utils.chat_formatting import box, text_to_file from .utils._internal_utils import send_to_owners_with_prefix_replaced if TYPE_CHECKING: @@ -81,6 +83,8 @@ PreInvokeCoroutine = Callable[[commands.Context], Awaitable[Any]] T_BIC = TypeVar("T_BIC", bound=PreInvokeCoroutine) UserOrRole = Union[int, discord.Role, discord.Member, discord.User] +_ = i18n.Translator("Core", __file__) + def _is_submodule(parent, child): return parent == child or child.startswith(parent + ".") @@ -2289,3 +2293,102 @@ class Red( failed_cogs=failures["cog"], unhandled=failures["unhandled"], ) + + async def send_interactive( + self, + channel: discord.abc.Messageable, + messages: Iterable[str], + *, + user: Optional[discord.User] = None, + box_lang: Optional[str] = None, + timeout: int = 15, + join_character: str = "", + ) -> List[discord.Message]: + """ + Send multiple messages interactively. + + The user will be prompted for whether or not they would like to view + the next message, one at a time. They will also be notified of how + many messages are remaining on each prompt. + + Parameters + ---------- + channel : discord.abc.Messageable + The channel to send the messages to. + messages : `iterable` of `str` + The messages to send. + user : discord.User + The user that can respond to the prompt. + When this is ``None``, any user can respond. + box_lang : Optional[str] + If specified, each message will be contained within a code block of + this language. + timeout : int + How long the user has to respond to the prompt before it times out. + After timing out, the bot deletes its prompt message. + join_character : str + The character used to join all the messages when the file output + is selected. + + Returns + ------- + List[discord.Message] + A list of sent messages. + """ + messages = tuple(messages) + ret = [] + # using dpy_commands.Context to keep the Messageable contract in full + if isinstance(channel, dpy_commands.Context): + # this is only necessary to ensure that `channel.delete_messages()` works + # when `ctx.channel` has that method + channel = channel.channel + + for idx, page in enumerate(messages, 1): + if box_lang is None: + msg = await channel.send(page) + else: + msg = await channel.send(box(page, lang=box_lang)) + ret.append(msg) + n_remaining = len(messages) - idx + if n_remaining > 0: + if n_remaining == 1: + prompt_text = _( + "There is still one message remaining. Type {command_1} to continue" + " or {command_2} to upload all contents as a file." + ) + else: + prompt_text = _( + "There are still {count} messages remaining. Type {command_1} to continue" + " or {command_2} to upload all contents as a file." + ) + query = await channel.send( + prompt_text.format(count=n_remaining, command_1="`more`", command_2="`file`") + ) + pred = MessagePredicate.lower_contained_in( + ("more", "file"), channel=channel, user=user + ) + try: + resp = await self.wait_for( + "message", + check=pred, + timeout=timeout, + ) + except asyncio.TimeoutError: + with contextlib.suppress(discord.HTTPException): + await query.delete() + break + else: + try: + await channel.delete_messages((query, resp)) + except (discord.HTTPException, AttributeError): + # In case the bot can't delete other users' messages, + # or is not a bot account + # or channel is a DM + with contextlib.suppress(discord.HTTPException): + await query.delete() + if pred.result == 1: + ret.append( + await channel.send(file=text_to_file(join_character.join(messages))) + ) + break + return ret diff --git a/redbot/core/commands/context.py b/redbot/core/commands/context.py index 47559914a..3363e4d26 100644 --- a/redbot/core/commands/context.py +++ b/redbot/core/commands/context.py @@ -9,8 +9,6 @@ import discord from discord.ext.commands import Context as DPYContext from .requires import PermState -from ..utils.chat_formatting import box, text_to_file -from ..utils.predicates import MessagePredicate from ..utils import can_user_react_in if TYPE_CHECKING: @@ -152,11 +150,12 @@ class Context(DPYContext): async def send_interactive( self, messages: Iterable[str], - box_lang: str = None, + box_lang: Optional[str] = None, timeout: int = 15, join_character: str = "", ) -> List[discord.Message]: - """Send multiple messages interactively. + """ + Send multiple messages interactively. The user will be prompted for whether or not they would like to view the next message, one at a time. They will also be notified of how @@ -167,7 +166,7 @@ class Context(DPYContext): messages : `iterable` of `str` The messages to send. box_lang : str - If specified, each message will be contained within a codeblock of + If specified, each message will be contained within a code block of this language. timeout : int How long the user has to respond to the prompt before it times out. @@ -176,53 +175,19 @@ class Context(DPYContext): The character used to join all the messages when the file output is selected. + Returns + ------- + List[discord.Message] + A list of sent messages. """ - messages = tuple(messages) - ret = [] - - for idx, page in enumerate(messages, 1): - if box_lang is None: - msg = await self.send(page) - else: - msg = await self.send(box(page, lang=box_lang)) - ret.append(msg) - n_remaining = len(messages) - idx - if n_remaining > 0: - if n_remaining == 1: - plural = "" - is_are = "is" - else: - plural = "s" - is_are = "are" - query = await self.send( - "There {} still {} message{} remaining. " - "Type `more` to continue or `file` to upload all contents as a file." - "".format(is_are, n_remaining, plural) - ) - pred = MessagePredicate.lower_contained_in(("more", "file"), self) - try: - resp = await self.bot.wait_for( - "message", - check=pred, - timeout=timeout, - ) - except asyncio.TimeoutError: - with contextlib.suppress(discord.HTTPException): - await query.delete() - break - else: - try: - await self.channel.delete_messages((query, resp)) - except (discord.HTTPException, AttributeError): - # In case the bot can't delete other users' messages, - # or is not a bot account - # or channel is a DM - with contextlib.suppress(discord.HTTPException): - await query.delete() - if pred.result == 1: - await self.send(file=text_to_file(join_character.join(messages))) - break - return ret + return await self.bot.send_interactive( + channel=self.channel, + messages=messages, + user=self.author, + box_lang=box_lang, + timeout=timeout, + join_character=join_character, + ) async def embed_colour(self): """ diff --git a/redbot/core/core_commands.py b/redbot/core/core_commands.py index 69c491d64..08241cf89 100644 --- a/redbot/core/core_commands.py +++ b/redbot/core/core_commands.py @@ -1446,21 +1446,22 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): **Arguments:** - `[public]` - Whether to send the traceback to the current context. Leave blank to send to your DMs. """ - if not public: - destination = ctx.author - else: - destination = ctx.channel + channel = ctx.channel if public else ctx.author if self.bot._last_exception: - for page in pagify(self.bot._last_exception, shorten_by=10): - try: - await destination.send(box(page, lang="py")) - except discord.HTTPException: - await ctx.channel.send( - "I couldn't send the traceback message to you in DM. " - "Either you blocked me or you disabled DMs in this server." - ) - return + try: + await self.bot.send_interactive( + channel, + pagify(self.bot._last_exception, shorten_by=10), + user=ctx.author, + box_lang="py", + ) + except discord.HTTPException: + await ctx.channel.send( + "I couldn't send the traceback message to you in DM. " + "Either you blocked me or you disabled DMs in this server." + ) + return if not public: await ctx.tick() else: