diff --git a/docs/_static/theme_overrides.css b/docs/_static/theme_overrides.css new file mode 100644 index 000000000..9077f3341 --- /dev/null +++ b/docs/_static/theme_overrides.css @@ -0,0 +1,11 @@ + +/* + * This overrides the style for the path to each object definition, whilst + * the name of the object remains unchanged. + * + * e.g. in the definition `redbot.core.Config`, `redbot.core` is the + * desclassname. + */ +code.descclassname { + font-weight: normal !important; +} diff --git a/docs/conf.py b/docs/conf.py index 5198f1ade..e39017f9a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -103,6 +103,9 @@ html_theme = 'sphinx_rtd_theme' # html_theme_options = {} html_context = { + 'css_files': [ + '_static/theme_overrides.css' + ], # Enable the "Edit in GitHub link within the header of each page. 'display_github': True, 'github_user': 'Cog-Creators', diff --git a/docs/framework_utils.rst b/docs/framework_utils.rst new file mode 100644 index 000000000..2b93531b9 --- /dev/null +++ b/docs/framework_utils.rst @@ -0,0 +1,17 @@ +.. red's core utils documentation + +================= +Utility Functions +================= + +Chat Formatting +=============== + +.. automodule:: redbot.core.utils.chat_formatting + :members: + +Mod Helpers +=========== + +.. automodule:: redbot.core.utils.mod + :members: diff --git a/docs/index.rst b/docs/index.rst index 4fdc8b226..80a6bacb7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -37,6 +37,7 @@ Welcome to Red - Discord Bot's documentation! framework_i18n framework_modlog framework_context + framework_utils diff --git a/redbot/core/utils/chat_formatting.py b/redbot/core/utils/chat_formatting.py index 221118af6..c701d471e 100644 --- a/redbot/core/utils/chat_formatting.py +++ b/redbot/core/utils/chat_formatting.py @@ -1,39 +1,145 @@ import itertools +from typing import List, Iterator -def error(text): +def error(text: str) -> str: + """Get text prefixed with an error emoji. + + Returns + ------- + str + The new message. + + """ return "\N{NO ENTRY SIGN} {}".format(text) -def warning(text): +def warning(text: str) -> str: + """Get text prefixed with a warning emoji. + + Returns + ------- + str + The new message. + + """ return "\N{WARNING SIGN} {}".format(text) -def info(text): +def info(text: str) -> str: + """Get text prefixed with an info emoji. + + Returns + ------- + str + The new message. + + """ return "\N{INFORMATION SOURCE} {}".format(text) -def question(text): +def question(text: str) -> str: + """Get text prefixed with a question emoji. + + Returns + ------- + str + The new message. + + """ return "\N{BLACK QUESTION MARK ORNAMENT} {}".format(text) -def bold(text): +def bold(text: str) -> str: + """Get the given text in bold. + + Parameters + ---------- + text : str + The text to be marked up. + + Returns + ------- + str + The marked up text. + + """ return "**{}**".format(text) -def box(text, lang=""): +def box(text: str, lang: str="") -> str: + """Get the given text in a code block. + + Parameters + ---------- + text : str + The text to be marked up. + lang : `str`, optional + The syntax highlighting language for the codeblock. + + Returns + ------- + str + The marked up text. + + """ ret = "```{}\n{}\n```".format(lang, text) return ret -def inline(text): +def inline(text: str) -> str: + """Get the given text as inline code. + + Parameters + ---------- + text : str + The text to be marked up. + + Returns + ------- + str + The marked up text. + + """ return "`{}`".format(text) -def italics(text): +def italics(text: str) -> str: + """Get the given text in italics. + + Parameters + ---------- + text : str + The text to be marked up. + + Returns + ------- + str + The marked up text. + + """ return "*{}*".format(text) -def bordered(text1: list, text2: list): +def bordered(text1: List[str], text2: List[str]) -> str: + """Get two blocks of text in a borders. + + Note + ---- + This will only work with a monospaced font. + + Parameters + ---------- + text1 : `list` of `str` + The 1st block of text, with each string being a new line. + text2 : `list` of `str` + The 2nd block of text. Should not be longer than ``text1``. + + Returns + ------- + str + The bordered text. + + """ width1, width2 = max((len(s1) + 9, len(s2) + 9) for s1 in text1 for s2 in text2) res = ['┌{}┐{}┌{}┐'.format("─"*width1, " "*4, "─"*width2)] flag = True @@ -50,9 +156,48 @@ def bordered(text1: list, text2: list): return "\n".join(res) -def pagify(text, delims=["\n"], *, priority=False, escape_mass_mentions=True, shorten_by=8, - page_length=2000): - """DOES NOT RESPECT MARKDOWN BOXES OR INLINE CODE""" +def pagify(text: str, + delims: List[str]=["\n"], + *, + priority: bool=False, + escape_mass_mentions: bool=True, + shorten_by: int=8, + page_length: int=2000) -> Iterator[str]: + """Generate multiple pages from the given text. + + Note + ---- + This does not respect code blocks or inline code. + + Parameters + ---------- + text : str + The content to pagify and send. + delims : `list` of `str`, optional + Characters where page breaks will occur. If no delimiters are found + in a page, the page will break after ``page_length`` characters. + By default this only contains the newline. + + Other Parameters + ---------------- + priority : `bool` + Set to :code:`True` to choose the page break delimiter based on the + order of ``delims``. Otherwise, the page will always break at the + last possible delimiter. + escape_mass_mentions : `bool` + If :code:`True`, any mass mentions (here or everyone) will be + silenced. + shorten_by : `int` + How much to shorten each page by. Defaults to 8. + page_length : `int` + The maximum length of each page. Defaults to 2000. + + Yields + ------ + `str` + Pages of the given text. + + """ in_text = text page_length -= shorten_by while len(in_text) > page_length: @@ -82,15 +227,59 @@ def pagify(text, delims=["\n"], *, priority=False, escape_mass_mentions=True, sh yield in_text -def strikethrough(text): +def strikethrough(text: str) -> str: + """Get the given text with a strikethrough. + + Parameters + ---------- + text : str + The text to be marked up. + + Returns + ------- + str + The marked up text. + + """ return "~~{}~~".format(text) -def underline(text): +def underline(text: str) -> str: + """Get the given text with an underline. + + Parameters + ---------- + text : str + The text to be marked up. + + Returns + ------- + str + The marked up text. + + """ return "__{}__".format(text) -def escape(text, *, mass_mentions=False, formatting=False): +def escape(text: str, *, mass_mentions: bool=False, + formatting: bool=False) -> str: + """Get text with all mass mentions or markdown escaped. + + Parameters + ---------- + text : str + The text to be escaped. + mass_mentions : `bool`, optional + Set to :code:`True` to escape mass mentions in the text. + formatting : `bool`, optional + Set to :code:`True` to escpae any markdown formatting in the text. + + Returns + ------- + str + The escaped text. + + """ if mass_mentions: text = text.replace("@everyone", "@\u200beveryone") text = text.replace("@here", "@\u200bhere") diff --git a/redbot/core/utils/mod.py b/redbot/core/utils/mod.py index a556ac46b..74e3f4b30 100644 --- a/redbot/core/utils/mod.py +++ b/redbot/core/utils/mod.py @@ -1,5 +1,6 @@ import asyncio -from typing import List +from datetime import timedelta +from typing import List, Iterable, Union import discord @@ -9,6 +10,32 @@ from redbot.core.bot import Red async def mass_purge(messages: List[discord.Message], channel: discord.TextChannel): + """Bulk delete messages from a channel. + + If more than 100 messages are supplied, the bot will delete 100 messages at + a time, sleeping between each action. + + Note + ---- + Messages must not be older than 14 days, and the bot must not be a user + account. + + Parameters + ---------- + messages : `list` of `discord.Message` + The messages to bulk delete. + channel : discord.TextChannel + The channel to delete messages from. + + Raises + ------ + discord.Forbidden + You do not have proper permissions to delete the messages or you’re not + using a bot account. + discord.HTTPException + Deleting the messages failed. + + """ while messages: if len(messages) > 1: await channel.delete_messages(messages[:100]) @@ -19,7 +46,17 @@ async def mass_purge(messages: List[discord.Message], await asyncio.sleep(1.5) -async def slow_deletion(messages: List[discord.Message]): +async def slow_deletion(messages: Iterable[discord.Message]): + """Delete a list of messages one at a time. + + Any exceptions raised when trying to delete the message will be silenced. + + Parameters + ---------- + messages : `iterable` of `discord.Message` + The messages to delete. + + """ for message in messages: try: await message.delete() @@ -28,23 +65,62 @@ async def slow_deletion(messages: List[discord.Message]): def get_audit_reason(author: discord.Member, reason: str = None): - """Helper function to construct a reason to be provided - as the reason to appear in the audit log.""" + """Construct a reason to appear in the audit log. + + Parameters + ---------- + author : discord.Member + The author behind the audit log action. + reason : str + The reason behidn the audit log action. + + Returns + ------- + str + The formatted audit log reason. + + """ return \ "Action requested by {} (ID {}). Reason: {}".format(author, author.id, reason) if reason else \ "Action requested by {} (ID {}).".format(author, author.id) -async def is_allowed_by_hierarchy( - bot: Red, settings: Config, server: discord.Guild, - mod: discord.Member, user: discord.Member): - if not await settings.guild(server).respect_hierarchy(): +async def is_allowed_by_hierarchy(bot: Red, + settings: Config, + guild: discord.Guild, + mod: discord.Member, + user: discord.Member): + if not await settings.guild(guild).respect_hierarchy(): return True - is_special = mod == server.owner or await bot.is_owner(mod) + is_special = mod == guild.owner or await bot.is_owner(mod) return mod.top_role.position > user.top_role.position or is_special -async def is_mod_or_superior(bot: Red, obj: discord.Message or discord.Member or discord.Role): +async def is_mod_or_superior( + bot: Red, obj: Union[discord.Message, discord.Member, discord.Role]): + """Check if an object has mod or superior permissions. + + If a message is passed, its author's permissions are checked. If a role is + passed, it simply checks if it is one of either the admin or mod roles. + + Parameters + ---------- + bot : redbot.core.bot.Red + The bot object. + obj : `discord.Message` or `discord.Member` or `discord.Role` + The object to check permissions for. + + Returns + ------- + bool + :code:`True` if the object has mod permissions. + + Raises + ------ + TypeError + If the wrong type of ``obj`` was passed. + + """ user = None if isinstance(obj, discord.Message): user = obj.author @@ -76,7 +152,20 @@ async def is_mod_or_superior(bot: Red, obj: discord.Message or discord.Member or return False -def strfdelta(delta): +def strfdelta(delta: timedelta): + """Format a timedelta object to a message with time units. + + Parameters + ---------- + delta : datetime.timedelta + The duration to parse. + + Returns + ------- + str + A message representing the timedelta with units. + + """ s = [] if delta.days: ds = '%i day' % delta.days @@ -97,7 +186,31 @@ def strfdelta(delta): return ' '.join(s) -async def is_admin_or_superior(bot: Red, obj: discord.Message or discord.Role or discord.Member): +async def is_admin_or_superior( + bot: Red, obj: Union[discord.Message, discord.Member, discord.Role]): + """Same as `is_mod_or_superior` except for admin permissions. + + If a message is passed, its author's permissions are checked. If a role is + passed, it simply checks if it is the admin role. + + Parameters + ---------- + bot : redbot.core.bot.Red + The bot object. + obj : `discord.Message` or `discord.Member` or `discord.Role` + The object to check permissions for. + + Returns + ------- + bool + :code:`True` if the object has admin permissions. + + Raises + ------ + TypeError + If the wrong type of ``obj`` was passed. + + """ user = None if isinstance(obj, discord.Message): user = obj.author