From 05616ae19ed685c261f13d99d6f6150e863795b7 Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sat, 12 Oct 2019 12:36:04 +1100 Subject: Refactor the `Help` command. - `redirect_output` has been adjusted to run the `delete_invocation` inside a task as the help command will wait for that to run before sending the help or doing anything else. - `pagination` has been adjusted to support deleting the paginated message if `cleanup` is True, and an optional `description` that is present through all pages of pagination. - The help command has been refactored to subclass `commands.HelpCommand`. This means that it now supports methods such as `ctx.send_help(ctx.command)`. - `help_cleanup` provides the opportunity to use the :x: reaction to cleanup help even with no pagination. - Pagination purely happens through the `LinePaginator`, forcing a pagination session with 1 line per page where we format the page style before sending it through. - Categories are properly dealt with by finding a match and sending a seperate help where a named tuple of the Category name, description and relevant cogs is the only parameter. - Choices for when a command was not found has been updated to include category names, cog names, aliases of group and command names, and include all subcommands and aliases. This should provide a more helpful output when an error message is sent - Sending command, group, cog, category and bot help has been split into different functions that are called from `command_callback`. This provides an easier way to alter future changes, and cleans up code considerably. - Important note: no outward facing formatting should have changed. Any desired changes can be discussed in review. --- bot/cogs/help.py | 708 +++++++++++++++++++----------------------------------- bot/decorators.py | 29 ++- bot/pagination.py | 25 +- 3 files changed, 287 insertions(+), 475 deletions(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 9607dbd8d..16fd62154 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -1,32 +1,44 @@ -import asyncio import itertools +import logging +from asyncio import TimeoutError from collections import namedtuple from contextlib import suppress -from typing import Union -from discord import Colour, Embed, HTTPException, Message, Reaction, User -from discord.ext import commands -from discord.ext.commands import Bot, CheckFailure, Cog as DiscordCog, Command, Context +from discord import Colour, Embed, HTTPException, Member, Message, Reaction, User +from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand from fuzzywuzzy import fuzz, process from bot import constants from bot.constants import Channels, STAFF_ROLES from bot.decorators import redirect_output -from bot.pagination import ( - DELETE_EMOJI, FIRST_EMOJI, LAST_EMOJI, - LEFT_EMOJI, LinePaginator, RIGHT_EMOJI, -) +from bot.pagination import DELETE_EMOJI, LinePaginator +log = logging.getLogger(__name__) -REACTIONS = { - FIRST_EMOJI: 'first', - LEFT_EMOJI: 'back', - RIGHT_EMOJI: 'next', - LAST_EMOJI: 'end', - DELETE_EMOJI: 'stop' -} +COMMANDS_PER_PAGE = 5 +PREFIX = constants.Bot.prefix -Cog = namedtuple('Cog', ['name', 'description', 'commands']) +Category = namedtuple("Category", ["name", "description", "cogs"]) + + +async def help_cleanup(bot: Bot, author: Member, message: Message) -> None: + """ + Runs the cleanup for the help command. + + Adds a :x: reaction that, when clicked, will delete the help message. + After a 300 second timeout, the reaction will be removed. + """ + def check(r: Reaction, u: User) -> bool: + """Checks the reaction is :x:, the author is original author and messages are the same.""" + return str(r) == DELETE_EMOJI and u.id == author.id and r.message.id == message.id + + await message.add_reaction(DELETE_EMOJI) + with suppress(HTTPException, TimeoutError): + _, _ = await bot.wait_for("reaction_add", check=check, timeout=300) + await message.delete() + return + + await message.remove_reaction(DELETE_EMOJI, bot.user) class HelpQueryNotFound(ValueError): @@ -44,22 +56,9 @@ class HelpQueryNotFound(ValueError): self.possible_matches = possible_matches -class HelpSession: +class CustomHelpCommand(HelpCommand): """ - An interactive session for bot and command help output. - - Expected attributes include: - * title: str - The title of the help message. - * query: Union[discord.ext.commands.Bot, discord.ext.commands.Command] - * description: str - The description of the query. - * pages: list[str] - A list of the help content split into manageable pages. - * message: `discord.Message` - The message object that's showing the help contents. - * destination: `discord.abc.Messageable` - Where the help message is to be sent to. + An interactive instance for the bot help command. Cogs can be grouped into custom categories. All cogs with the same category will be displayed under a single category name in the help output. Custom categories are defined inside the cogs @@ -68,499 +67,296 @@ class HelpSession: the regular description (class docstring) of the first cog found in the category. """ - def __init__( - self, - ctx: Context, - *command, - cleanup: bool = False, - only_can_run: bool = True, - show_hidden: bool = False, - max_lines: int = 15 - ): - """Creates an instance of the HelpSession class.""" - self._ctx = ctx - self._bot = ctx.bot - self.title = "Command Help" - - # set the query details for the session - if command: - query_str = ' '.join(command) - self.query = self._get_query(query_str) - self.description = self.query.description or self.query.help - else: - self.query = ctx.bot - self.description = self.query.description - self.author = ctx.author - self.destination = ctx.channel - - # set the config for the session - self._cleanup = cleanup - self._only_can_run = only_can_run - self._show_hidden = show_hidden - self._max_lines = max_lines - - # init session states - self._pages = None - self._current_page = 0 - self.message = None - self._timeout_task = None - self.reset_timeout() - - def _get_query(self, query: str) -> Union[Command, Cog]: + def __init__(self): + super().__init__(command_attrs={"help": "Shows help for bot commands"}) + + @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES) + async def prepare_help_command(self, ctx: Context, command: str = None) -> None: + """Adjust context to redirect to a new channel if required.""" + self.context = ctx + + async def command_callback(self, ctx: Context, *, command: str = None) -> None: """Attempts to match the provided query with a valid command or cog.""" - command = self._bot.get_command(query) - if command: - return command + # the only reason we need to tamper with this is because d.py does not support "categories", + # so we need to deal with them ourselves. + + # handle any command redirection and adjust context channel accordingly. + await self.prepare_help_command(ctx, command=command) + bot = ctx.bot + + if command is None: + # quick and easy, send bot help if command is none + mapping = self.get_bot_mapping() + await self.send_bot_help(mapping) + return - # Find all cog categories that match. cog_matches = [] description = None - for cog in self._bot.cogs.values(): - if hasattr(cog, "category") and cog.category == query: + for cog in bot.cogs.values(): + if hasattr(cog, "category") and cog.category == command: cog_matches.append(cog) if hasattr(cog, "category_description"): description = cog.category_description - # Try to search by cog name if no categories match. - if not cog_matches: - cog = self._bot.cogs.get(query) + if cog_matches: + category = Category(name=command, description=description, cogs=cog_matches) + await self.send_category_help(category) + return - # Don't consider it a match if the cog has a category. - if cog and not hasattr(cog, "category"): - cog_matches = [cog] + # it's either a cog, group, command or subcommand, let super deal with it + await super().command_callback(ctx, command=command) - if cog_matches: - cog = cog_matches[0] - cmds = (cog.get_commands() for cog in cog_matches) # Commands of all cogs + def get_all_help_choices(self) -> set: + """ + Get all the possible options for getting help in the bot. - return Cog( - name=cog.category if hasattr(cog, "category") else cog.qualified_name, - description=description or cog.description, - commands=tuple(itertools.chain.from_iterable(cmds)) # Flatten the list - ) + These include: + - Category names + - Cog names + - Group command names (and aliases) + - Command names (and aliases) + - Subcommand names (with parent group and aliases for subcommand, but not including aliases for group) + + Options and choices are case sensitive. + """ + # first get all commands including subcommands and full command name aliases + choices = set() + for c in self.context.bot.walk_commands(): + # the the command or group name + choices.add(str(c)) + + # all aliases if it's just a command + choices.update(n for n in c.aliases if isinstance(c, Command)) + + # else aliases with parent if group. we need to strip() in case it's a Command and `full_parent` is None, + # otherwise we get 2 commands: ` help` and normal `help`. + # We could do case-by-case with f-string but this is the cleanest solution + choices.update(f"{c.full_parent_name} {a}".strip() for a in c.aliases) - self._handle_not_found(query) + # all cog names + choices.update(self.context.bot.cogs) - def _handle_not_found(self, query: str) -> None: + # all category names + choices.update(getattr(n, "category", None) for n in self.context.bot.cogs if hasattr(n, "category")) + return choices + + def command_not_found(self, string: str) -> "HelpQueryNotFound": """ - Handles when a query does not match a valid command or cog. + Handles when a query does not match a valid command, group, cog or category. - Will pass on possible close matches along with the `HelpQueryNotFound` exception. + Will return an instance of the `HelpQueryNotFound` exception with the error message and possible matches. """ - # Combine command and cog names - choices = list(self._bot.all_commands) + list(self._bot.cogs) + choices = self.get_all_help_choices() + result = process.extractBests(string, choices, scorer=fuzz.ratio, score_cutoff=90) - result = process.extractBests(query, choices, scorer=fuzz.ratio, score_cutoff=90) + return HelpQueryNotFound(f'Query "{string}" not found.', dict(result)) - raise HelpQueryNotFound(f'Query "{query}" not found.', dict(result)) + def subcommand_not_found(self, command: Command, string: str) -> "HelpQueryNotFound": + """ + Redirects the error to `command_not_found`. - async def timeout(self, seconds: int = 30) -> None: - """Waits for a set number of seconds, then stops the help session.""" - await asyncio.sleep(seconds) - await self.stop() + `command_not_found` deals with searching and getting best choices for both commands and subcommands. + """ + return self.command_not_found(f"{command.qualified_name} {string}") - def reset_timeout(self) -> None: - """Cancels the original timeout task and sets it again from the start.""" - # cancel original if it exists - if self._timeout_task: - if not self._timeout_task.cancelled(): - self._timeout_task.cancel() + async def send_error_message(self, error: HelpQueryNotFound) -> None: + """Send the error message to the channel.""" + embed = Embed(colour=Colour.red(), title=str(error)) - # recreate the timeout task - self._timeout_task = self._bot.loop.create_task(self.timeout()) + if getattr(error, "possible_matches", None): + matches = "\n".join(f"`{n}`" for n in error.possible_matches.keys()) + embed.add_field(name="Did you mean:", value=matches) - async def on_reaction_add(self, reaction: Reaction, user: User) -> None: - """Event handler for when reactions are added on the help message.""" - # ensure it was the relevant session message - if reaction.message.id != self.message.id: - return + await self.context.send(embed=embed) - # ensure it was the session author who reacted - if user.id != self.author.id: - return + async def command_formatting(self, command: Command) -> Embed: + """ + Takes a command and turns it into an embed. - emoji = str(reaction.emoji) + It will add an author, command signature + help, aliases and a note if the user can't run the command. + """ + embed = Embed() + embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) - # check if valid action - if emoji not in REACTIONS: - return + parent = command.full_parent_name - self.reset_timeout() + name = str(command) if not parent else f"{parent} {command.name}" + fmt = f"**```{PREFIX}{name} {command.signature}```**\n" - # Run relevant action method - action = getattr(self, f'do_{REACTIONS[emoji]}', None) - if action: - await action() + # show command aliases + aliases = ", ".join(f"`{a}`" if not parent else f"`{parent} {a}`" for a in command.aliases) + if aliases: + fmt += f"**Can also use:** {aliases}\n\n" - # remove the added reaction to prep for re-use - with suppress(HTTPException): - await self.message.remove_reaction(reaction, user) + # check if the user is allowed to run this command + if not await command.can_run(self.context): + fmt += "**You cannot run this command.**" - async def on_message_delete(self, message: Message) -> None: - """Closes the help session when the help message is deleted.""" - if message.id == self.message.id: - await self.stop() + fmt += f"*{command.help or 'No details provided.'}*\n" + embed.description = fmt - async def prepare(self) -> None: - """Sets up the help session pages, events, message and reactions.""" - # create paginated content - await self.build_pages() + return embed - # setup listeners - self._bot.add_listener(self.on_reaction_add) - self._bot.add_listener(self.on_message_delete) + async def send_command_help(self, command: Command) -> None: + """Send help for a single command.""" + embed = await self.command_formatting(command) + message = await self.context.send(embed=embed) + await help_cleanup(self.context.bot, self.context.author, message) - # Send the help message - await self.update_page() - self.add_reactions() + async def send_group_help(self, group: Group) -> None: + """Sends help for a group command.""" + subcommands = group.commands - def add_reactions(self) -> None: - """Adds the relevant reactions to the help message based on if pagination is required.""" - # if paginating - if len(self._pages) > 1: - for reaction in REACTIONS: - self._bot.loop.create_task(self.message.add_reaction(reaction)) + if len(subcommands) == 0: + # no subcommands, just treat it like a regular command + await self.send_command_help(group) + return - # if single-page - else: - self._bot.loop.create_task(self.message.add_reaction(DELETE_EMOJI)) + # remove commands that the user can't run and are hidden, and sort by name + _commands = await self.filter_commands(subcommands, sort=True) - def _category_key(self, cmd: Command) -> str: + embed = await self.command_formatting(group) + + # add in subcommands with brief help + # note: the extra f-string around the signature is necessary because otherwise an extra space before the + # last back tick is present. + fmt = "\n".join( + f"**`{PREFIX}{c.qualified_name}{f' {c.signature}' if c.signature else ''}`**" + f"\n*{c.short_doc or 'No details provided.'}*" for c in _commands + ) + embed.description += f"\n**Subcommands:**\n{fmt}" + message = await self.context.send(embed=embed) + await help_cleanup(self.context.bot, self.context.author, message) + + async def send_cog_help(self, cog: Cog) -> None: + """Send help for a cog.""" + # sort commands by name, and remove any the user cant run or are hidden. + _commands = await self.filter_commands(cog.get_commands(), sort=True) + + embed = Embed() + embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) + embed.description = f"**{cog.qualified_name}**\n*{cog.description}*\n\n**Commands:**\n" + + lines = [ + f"`{PREFIX}{c.qualified_name}{f' {c.signature}' if c.signature else ''}`" + f"\n*{c.short_doc or 'No details provided.'}*\n" for c in _commands + ] + embed.description += "\n".join(n for n in lines) + + message = await self.context.send(embed=embed) + await help_cleanup(self.context.bot, self.context.author, message) + + @staticmethod + def _category_key(cmd: Command) -> str: """ Returns a cog name of a given command for use as a key for `sorted` and `groupby`. A zero width space is used as a prefix for results with no cogs to force them last in ordering. """ if cmd.cog: - try: + with suppress(AttributeError): if cmd.cog.category: - return f'**{cmd.cog.category}**' - except AttributeError: - pass - - return f'**{cmd.cog_name}**' + return f"**{cmd.cog.category}**" + return f"**{cmd.cog_name}**" else: return "**\u200bNo Category:**" - def _get_command_params(self, cmd: Command) -> str: + async def send_category_help(self, category: Category) -> None: """ - Returns the command usage signature. + Sends help for a bot category. - This is a custom implementation of `command.signature` in order to format the command - signature without aliases. + This sends a brief help for all commands in all cogs registered to the category. """ - results = [] - for name, param in cmd.clean_params.items(): - - # if argument has a default value - if param.default is not param.empty: - - if isinstance(param.default, str): - show_default = param.default - else: - show_default = param.default is not None - - # if default is not an empty string or None - if show_default: - results.append(f'[{name}={param.default}]') - else: - results.append(f'[{name}]') - - # if variable length argument - elif param.kind == param.VAR_POSITIONAL: - results.append(f'[{name}...]') - - # if required - else: - results.append(f'<{name}>') - - return f"{cmd.name} {' '.join(results)}" - - async def build_pages(self) -> None: - """Builds the list of content pages to be paginated through in the help message, as a list of str.""" - # Use LinePaginator to restrict embed line height - paginator = LinePaginator(prefix='', suffix='', max_lines=self._max_lines) - - prefix = constants.Bot.prefix + embed = Embed() + embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) - # show signature if query is a command - if isinstance(self.query, commands.Command): - signature = self._get_command_params(self.query) - parent = self.query.full_parent_name + ' ' if self.query.parent else '' - paginator.add_line(f'**```{prefix}{parent}{signature}```**') - - # show command aliases - aliases = ', '.join(f'`{a}`' for a in self.query.aliases) - if aliases: - paginator.add_line(f'**Can also use:** {aliases}\n') - - if not await self.query.can_run(self._ctx): - paginator.add_line('***You cannot run this command.***\n') - - # show name if query is a cog - if isinstance(self.query, Cog): - paginator.add_line(f'**{self.query.name}**') - - if self.description: - paginator.add_line(f'*{self.description}*') + all_commands = [] + for c in category.cogs: + all_commands.extend(c.get_commands()) - # list all children commands of the queried object - if isinstance(self.query, (commands.GroupMixin, Cog)): - - # remove hidden commands if session is not wanting hiddens - if not self._show_hidden: - filtered = [c for c in self.query.commands if not c.hidden] - else: - filtered = self.query.commands - - # if after filter there are no commands, finish up - if not filtered: - self._pages = paginator.pages - return - - # set category to Commands if cog - if isinstance(self.query, Cog): - grouped = (('**Commands:**', self.query.commands),) - - # set category to Subcommands if command - elif isinstance(self.query, commands.Command): - grouped = (('**Subcommands:**', self.query.commands),) - - # don't show prefix for subcommands - prefix = '' - - # otherwise sort and organise all commands into categories - else: - cat_sort = sorted(filtered, key=self._category_key) - grouped = itertools.groupby(cat_sort, key=self._category_key) - - # process each category - for category, cmds in grouped: - cmds = sorted(cmds, key=lambda c: c.name) - - # if there are no commands, skip category - if len(cmds) == 0: - continue - - cat_cmds = [] - - # format details for each child command - for command in cmds: - - # skip if hidden and hide if session is set to - if command.hidden and not self._show_hidden: - continue - - # see if the user can run the command - strikeout = '' - - # Patch to make the !help command work outside of #bot-commands again - # This probably needs a proper rewrite, but this will make it work in - # the mean time. - try: - can_run = await command.can_run(self._ctx) - except CheckFailure: - can_run = False - - if not can_run: - # skip if we don't show commands they can't run - if self._only_can_run: - continue - strikeout = '~~' + filtered_commands = await self.filter_commands(all_commands, sort=True, key=self._category_key) - signature = self._get_command_params(command) - info = f"{strikeout}**`{prefix}{signature}`**{strikeout}" - - # handle if the command has no docstring - if command.short_doc: - cat_cmds.append(f'{info}\n*{command.short_doc}*') - else: - cat_cmds.append(f'{info}\n*No details provided.*') - - # state var for if the category should be added next - print_cat = 1 - new_page = True + lines = [ + f"`{PREFIX}{c.qualified_name}{f' {c.signature}' if c.signature else ''}`" + f"\n*{c.short_doc or 'No details provided.'}*" for c in filtered_commands + ] - for details in cat_cmds: - - # keep details together, paginating early if it won't fit - lines_adding = len(details.split('\n')) + print_cat - if paginator._linecount + lines_adding > self._max_lines: - paginator._linecount = 0 - new_page = True - paginator.close_page() - - # new page so print category title again - print_cat = 1 + description = f"**{category.name}**\n*{category.description}*\n\n**Commands:**" - if print_cat: - if new_page: - paginator.add_line('') - paginator.add_line(category) - print_cat = 0 - - paginator.add_line(details) + await LinePaginator.paginate( + lines, self.context, embed, max_lines=COMMANDS_PER_PAGE, + max_size=2040, description=description, cleanup=True + ) - # save organised pages to session - self._pages = paginator.pages + async def send_bot_help(self, mapping: dict) -> None: + """Sends help for all bot commands and cogs.""" + bot = self.context.bot - def embed_page(self, page_number: int = 0) -> Embed: - """Returns an Embed with the requested page formatted within.""" embed = Embed() + embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) - # if command or cog, add query to title for pages other than first - if isinstance(self.query, (commands.Command, Cog)) and page_number > 0: - title = f'Command Help | "{self.query.name}"' - else: - title = self.title + filter_commands = await self.filter_commands(bot.commands, sort=True, key=self._category_key) - embed.set_author(name=title, icon_url=constants.Icons.questionmark) - embed.description = self._pages[page_number] + lines = [] - # add page counter to footer if paginating - page_count = len(self._pages) - if page_count > 1: - embed.set_footer(text=f'Page {self._current_page+1} / {page_count}') + for cog_or_category, _commands in itertools.groupby(filter_commands, key=self._category_key): + sorted_commands = sorted(_commands, key=lambda c: c.name) - return embed + if len(sorted_commands) == 0: + continue - async def update_page(self, page_number: int = 0) -> None: - """Sends the intial message, or changes the existing one to the given page number.""" - self._current_page = page_number - embed_page = self.embed_page(page_number) + fmt = [ + f"`{PREFIX}{c.qualified_name}{f' {c.signature}' if c.signature else ''}`" + f"\n*{c.short_doc or 'No details provided.'}*" for c in sorted_commands + ] - if not self.message: - self.message = await self.destination.send(embed=embed_page) - else: - await self.message.edit(embed=embed_page) + # we can't embed a '\n'.join() inside an f-string so this is a bit of a compromise + def get_fmt(i: int) -> str: + """Get a formatted version of commands for an index.""" + return "\n".join(fmt[i:i+COMMANDS_PER_PAGE]) - @classmethod - async def start(cls, ctx: Context, *command, **options) -> "HelpSession": - """ - Create and begin a help session based on the given command context. - - Available options kwargs: - * cleanup: Optional[bool] - Set to `True` to have the message deleted on session end. Defaults to `False`. - * only_can_run: Optional[bool] - Set to `True` to hide commands the user can't run. Defaults to `False`. - * show_hidden: Optional[bool] - Set to `True` to include hidden commands. Defaults to `False`. - * max_lines: Optional[int] - Sets the max number of lines the paginator will add to a single page. Defaults to 20. - """ - session = cls(ctx, *command, **options) - await session.prepare() - - return session - - async def stop(self) -> None: - """Stops the help session, removes event listeners and attempts to delete the help message.""" - self._bot.remove_listener(self.on_reaction_add) - self._bot.remove_listener(self.on_message_delete) - - # ignore if permission issue, or the message doesn't exist - with suppress(HTTPException, AttributeError): - if self._cleanup: - await self.message.delete() - else: - await self.message.clear_reactions() - - @property - def is_first_page(self) -> bool: - """Check if session is currently showing the first page.""" - return self._current_page == 0 - - @property - def is_last_page(self) -> bool: - """Check if the session is currently showing the last page.""" - return self._current_page == (len(self._pages)-1) - - async def do_first(self) -> None: - """Event that is called when the user requests the first page.""" - if not self.is_first_page: - await self.update_page(0) - - async def do_back(self) -> None: - """Event that is called when the user requests the previous page.""" - if not self.is_first_page: - await self.update_page(self._current_page-1) - - async def do_next(self) -> None: - """Event that is called when the user requests the next page.""" - if not self.is_last_page: - await self.update_page(self._current_page+1) - - async def do_end(self) -> None: - """Event that is called when the user requests the last page.""" - if not self.is_last_page: - await self.update_page(len(self._pages)-1) - - async def do_stop(self) -> None: - """Event that is called when the user requests to stop the help session.""" - await self.message.delete() - - -class Help(DiscordCog): - """Custom Embed Pagination Help feature.""" + # this is a bit yuck because moderation category has 8 commands which needs to be split over 2 pages. + # pretty much it only splits that category, but also gives the number of commands it's adding to + # the pages every iteration so we can easily use this below rather than trying to split the string. + lines.extend( + ( + (f"**{cog_or_category}**\n{get_fmt(i)}", len(fmt[i:i+COMMANDS_PER_PAGE])) + for i in range(0, len(sorted_commands), COMMANDS_PER_PAGE) + ) + ) - @commands.command('help') - @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES) - async def new_help(self, ctx: Context, *commands) -> None: - """Shows Command Help.""" - try: - await HelpSession.start(ctx, *commands) - except HelpQueryNotFound as error: - embed = Embed() - embed.colour = Colour.red() - embed.title = str(error) + pages = [] + counter = 0 + formatted = "" + for (fmt, length) in lines: + counter += length + if counter > COMMANDS_PER_PAGE: + # force a new page on paginator even if it falls short of the max pages + # since we still want to group categories/cogs. + counter = length + pages.append(formatted) + formatted = f"{fmt}\n\n" + continue + formatted += f"{fmt}\n\n" - if error.possible_matches: - matches = '\n'.join(error.possible_matches.keys()) - embed.description = f'**Did you mean:**\n`{matches}`' + await LinePaginator.paginate(pages, self.context, embed=embed, max_lines=1, max_size=2040, cleanup=True) - await ctx.send(embed=embed) +class Help(Cog): + """Custom Embed Pagination Help feature.""" -def unload(bot: Bot) -> None: - """ - Reinstates the original help command. + def __init__(self, bot: Bot) -> None: + self.bot = bot + self.old_help_command = bot.help_command + bot.help_command = CustomHelpCommand() + bot.help_command.cog = self - This is run if the cog raises an exception on load, or if the extension is unloaded. - """ - bot.remove_command('help') - bot.add_command(bot._old_help) + def cog_unload(self) -> None: + """Reset the help command when the cog is unloaded.""" + self.bot.help_command = self.old_help_command def setup(bot: Bot) -> None: - """ - The setup for the help extension. - - This is called automatically on `bot.load_extension` being run. - - Stores the original help command instance on the `bot._old_help` attribute for later - reinstatement, before removing it from the command registry so the new help command can be - loaded successfully. - - If an exception is raised during the loading of the cog, `unload` will be called in order to - reinstate the original help command. - """ - bot._old_help = bot.get_command('help') - bot.remove_command('help') - - try: - bot.add_cog(Help()) - except Exception: - unload(bot) - raise - - -def teardown(bot: Bot) -> None: - """ - The teardown for the help extension. - - This is called automatically on `bot.unload_extension` being run. - - Calls `unload` in order to reinstate the original help command. - """ - unload(bot) + """Load the Help cog.""" + bot.add_cog(Help(bot)) + log.info("Cog loaded: Help") diff --git a/bot/decorators.py b/bot/decorators.py index 935df4af0..b31324f36 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -6,7 +6,7 @@ from functools import wraps from typing import Callable, Container, Union from weakref import WeakValueDictionary -from discord import Colour, Embed, Member +from discord import Colour, Embed, Member, Message from discord.errors import NotFound from discord.ext import commands from discord.ext.commands import CheckFailure, Cog, Context @@ -98,6 +98,20 @@ def locked() -> Callable: return wrap +async def delete_invocation(ctx: Context, message: Message) -> None: + """Task to delete the invocation and user redirection messages.""" + if RedirectOutput.delete_invocation: + await sleep(RedirectOutput.delete_delay) + + with suppress(NotFound): + await message.delete() + log.trace("Redirect output: Deleted user redirection message") + + with suppress(NotFound): + await ctx.message.delete() + log.trace("Redirect output: Deleted invocation message") + + def redirect_output(destination_channel: int, bypass_roles: Container[int] = None) -> Callable: """ Changes the channel in the context of the command to redirect the output to a certain channel. @@ -131,17 +145,8 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non f"Hey, {ctx.author.mention}, you can find the output of your command here: " f"{redirect_channel.mention}" ) - - if RedirectOutput.delete_invocation: - await sleep(RedirectOutput.delete_delay) - - with suppress(NotFound): - await message.delete() - log.trace("Redirect output: Deleted user redirection message") - - with suppress(NotFound): - await ctx.message.delete() - log.trace("Redirect output: Deleted invocation message") + # we need to run it in a task for the help command - which gets held up if waiting for invocation deletion. + ctx.bot.loop.create_task(delete_invocation(ctx, message)) return inner return wrap diff --git a/bot/pagination.py b/bot/pagination.py index 76082f459..f2cf192c4 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -1,8 +1,9 @@ import asyncio import logging +from contextlib import suppress from typing import Iterable, List, Optional, Tuple -from discord import Embed, Member, Message, Reaction +from discord import Embed, HTTPException, Member, Message, Reaction from discord.abc import User from discord.ext.commands import Context, Paginator @@ -99,7 +100,9 @@ class LinePaginator(Paginator): timeout: int = 300, footer_text: str = None, url: str = None, - exception_on_empty_embed: bool = False + exception_on_empty_embed: bool = False, + description: str = '', + cleanup: bool = False ) -> Optional[Message]: """ Use a paginator and set of reactions to provide pagination over a set of lines. @@ -111,6 +114,9 @@ class LinePaginator(Paginator): Pagination will also be removed automatically if no reaction is added for five minutes (300 seconds). + The description is a string that should appear at the top of every page. + If cleanup is True, the paginated message will be deleted when :x: reaction is added. + Example: >>> embed = Embed() >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) @@ -161,7 +167,7 @@ class LinePaginator(Paginator): log.debug(f"Paginator created with {len(paginator.pages)} pages") - embed.description = paginator.pages[current_page] + embed.description = description + paginator.pages[current_page] if len(paginator.pages) <= 1: if footer_text: @@ -205,6 +211,11 @@ class LinePaginator(Paginator): if reaction.emoji == DELETE_EMOJI: log.debug("Got delete reaction") + if cleanup: + with suppress(HTTPException, AttributeError): + log.debug("Deleting help message") + await message.delete() + return break if reaction.emoji == FIRST_EMOJI: @@ -215,7 +226,7 @@ class LinePaginator(Paginator): embed.description = "" await message.edit(embed=embed) - embed.description = paginator.pages[current_page] + embed.description = description + paginator.pages[current_page] if footer_text: embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") else: @@ -230,7 +241,7 @@ class LinePaginator(Paginator): embed.description = "" await message.edit(embed=embed) - embed.description = paginator.pages[current_page] + embed.description = description + paginator.pages[current_page] if footer_text: embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") else: @@ -249,7 +260,7 @@ class LinePaginator(Paginator): embed.description = "" await message.edit(embed=embed) - embed.description = paginator.pages[current_page] + embed.description = description + paginator.pages[current_page] if footer_text: embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") @@ -270,7 +281,7 @@ class LinePaginator(Paginator): embed.description = "" await message.edit(embed=embed) - embed.description = paginator.pages[current_page] + embed.description = description + paginator.pages[current_page] if footer_text: embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") -- cgit v1.2.3 From 87f9fdda65e7a9c425e727b766d57dda2ce5a364 Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sat, 12 Oct 2019 16:48:42 +1100 Subject: Minor formatting changes to align with current help. --- bot/cogs/help.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 16fd62154..dd19dffd3 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -190,7 +190,7 @@ class CustomHelpCommand(HelpCommand): # check if the user is allowed to run this command if not await command.can_run(self.context): - fmt += "**You cannot run this command.**" + fmt += "***You cannot run this command.***\n\n" fmt += f"*{command.help or 'No details provided.'}*\n" embed.description = fmt @@ -239,7 +239,7 @@ class CustomHelpCommand(HelpCommand): lines = [ f"`{PREFIX}{c.qualified_name}{f' {c.signature}' if c.signature else ''}`" - f"\n*{c.short_doc or 'No details provided.'}*\n" for c in _commands + f"\n*{c.short_doc or 'No details provided.'}*" for c in _commands ] embed.description += "\n".join(n for n in lines) -- cgit v1.2.3 From 5ebb87d4dbdf2c4c415f4a37b01746909f2b67b9 Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sun, 13 Oct 2019 17:03:06 +1100 Subject: Add a special case for when the help command invokes wolfram checks. - Before, running `!help` would invoke the cooldown check, and increase the cooldown counter unnecessarily as no wolfram API calls were being made. - Once `!help` was called enough, the bot would send an error embed to let you know your wolfram cooldown has expired. --- bot/cogs/wolfram.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py index ab0ed2472..3da349bd1 100644 --- a/bot/cogs/wolfram.py +++ b/bot/cogs/wolfram.py @@ -59,6 +59,14 @@ def custom_cooldown(*ignore: List[int]) -> Callable: A list of roles may be provided to ignore the per-user cooldown """ async def predicate(ctx: Context) -> bool: + if ctx.invoked_with == 'help': + # if the invoked command is help we don't want to increase the ratelimits since it's not actually + # invoking the command/making a request, so instead just check if the user/guild are on cooldown. + guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0 # if guild is on cooldown + if not any(r.id in ignore for r in ctx.author.roles): # check user bucket if user is not ignored + return guild_cooldown and not usercd.get_bucket(ctx.message).get_tokens() == 0 + return guild_cooldown + user_bucket = usercd.get_bucket(ctx.message) if all(role.id not in ignore for role in ctx.author.roles): -- cgit v1.2.3 From 79d5b2df86d2676caed7665f9e083ffbb984ebc2 Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sun, 13 Oct 2019 17:08:27 +1100 Subject: Few changes to keep formatting same as current - Change `add_field` back to `description` for error message possible matches - Only add `Commands` and `Subcommands` if subcommands exist to cog/group/command help --- bot/cogs/help.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index dd19dffd3..3513c3373 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -165,7 +165,7 @@ class CustomHelpCommand(HelpCommand): if getattr(error, "possible_matches", None): matches = "\n".join(f"`{n}`" for n in error.possible_matches.keys()) - embed.add_field(name="Did you mean:", value=matches) + embed.description = f"**Did you mean:**\n{matches}" await self.context.send(embed=embed) @@ -224,7 +224,9 @@ class CustomHelpCommand(HelpCommand): f"**`{PREFIX}{c.qualified_name}{f' {c.signature}' if c.signature else ''}`**" f"\n*{c.short_doc or 'No details provided.'}*" for c in _commands ) - embed.description += f"\n**Subcommands:**\n{fmt}" + if fmt: + embed.description += f"\n**Subcommands:**\n{fmt}" + message = await self.context.send(embed=embed) await help_cleanup(self.context.bot, self.context.author, message) @@ -235,13 +237,15 @@ class CustomHelpCommand(HelpCommand): embed = Embed() embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) - embed.description = f"**{cog.qualified_name}**\n*{cog.description}*\n\n**Commands:**\n" + embed.description = f"**{cog.qualified_name}**\n*{cog.description}*" lines = [ f"`{PREFIX}{c.qualified_name}{f' {c.signature}' if c.signature else ''}`" f"\n*{c.short_doc or 'No details provided.'}*" for c in _commands ] - embed.description += "\n".join(n for n in lines) + if lines: + embed.description += "\n\n**Commands:**\n" + embed.description += "\n".join(n for n in lines) message = await self.context.send(embed=embed) await help_cleanup(self.context.bot, self.context.author, message) @@ -281,7 +285,10 @@ class CustomHelpCommand(HelpCommand): f"\n*{c.short_doc or 'No details provided.'}*" for c in filtered_commands ] - description = f"**{category.name}**\n*{category.description}*\n\n**Commands:**" + description = f"**{category.name}**\n*{category.description}*" + + if lines: + description += "\n\n**Commands:**" await LinePaginator.paginate( lines, self.context, embed, max_lines=COMMANDS_PER_PAGE, @@ -339,6 +346,10 @@ class CustomHelpCommand(HelpCommand): continue formatted += f"{fmt}\n\n" + if formatted: + # add any remaining command help that didn't get added in the last iteration above. + pages.append(formatted) + await LinePaginator.paginate(pages, self.context, embed=embed, max_lines=1, max_size=2040, cleanup=True) -- cgit v1.2.3 From d8384b214bb0085bc53af9ef39386662957274f9 Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sun, 3 Nov 2019 14:33:10 +1100 Subject: Show a maximum of 8 commands per page rather than 5. --- bot/cogs/help.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 3513c3373..cd8c797b4 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -15,7 +15,7 @@ from bot.pagination import DELETE_EMOJI, LinePaginator log = logging.getLogger(__name__) -COMMANDS_PER_PAGE = 5 +COMMANDS_PER_PAGE = 8 PREFIX = constants.Bot.prefix Category = namedtuple("Category", ["name", "description", "cogs"]) -- cgit v1.2.3 From af44f629dc5666ea17dd435dd3329784651c4412 Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sat, 16 Nov 2019 21:07:24 +1100 Subject: Apply suggestions from review - Description was the same as prefix parameter of paginator - Cleanup is redundant pending closure of #514 - Clean/fix couple if statements in help.py --- bot/cogs/help.py | 14 +++++++------- bot/pagination.py | 23 ++++++----------------- 2 files changed, 13 insertions(+), 24 deletions(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index cd8c797b4..76d584d64 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -126,7 +126,8 @@ class CustomHelpCommand(HelpCommand): choices.add(str(c)) # all aliases if it's just a command - choices.update(n for n in c.aliases if isinstance(c, Command)) + if isinstance(c, Command): + choices.update(c.aliases) # else aliases with parent if group. we need to strip() in case it's a Command and `full_parent` is None, # otherwise we get 2 commands: ` help` and normal `help`. @@ -137,7 +138,7 @@ class CustomHelpCommand(HelpCommand): choices.update(self.context.bot.cogs) # all category names - choices.update(getattr(n, "category", None) for n in self.context.bot.cogs if hasattr(n, "category")) + choices.update(n.category for n in self.context.bot.cogs if hasattr(n, "category")) return choices def command_not_found(self, string: str) -> "HelpQueryNotFound": @@ -164,7 +165,7 @@ class CustomHelpCommand(HelpCommand): embed = Embed(colour=Colour.red(), title=str(error)) if getattr(error, "possible_matches", None): - matches = "\n".join(f"`{n}`" for n in error.possible_matches.keys()) + matches = "\n".join(f"`{n}`" for n in error.possible_matches) embed.description = f"**Did you mean:**\n{matches}" await self.context.send(embed=embed) @@ -285,14 +286,13 @@ class CustomHelpCommand(HelpCommand): f"\n*{c.short_doc or 'No details provided.'}*" for c in filtered_commands ] - description = f"**{category.name}**\n*{category.description}*" + description = f"```**{category.name}**\n*{category.description}*" if lines: description += "\n\n**Commands:**" await LinePaginator.paginate( - lines, self.context, embed, max_lines=COMMANDS_PER_PAGE, - max_size=2040, description=description, cleanup=True + lines, self.context, embed, prefix=description, max_lines=COMMANDS_PER_PAGE, max_size=2040 ) async def send_bot_help(self, mapping: dict) -> None: @@ -350,7 +350,7 @@ class CustomHelpCommand(HelpCommand): # add any remaining command help that didn't get added in the last iteration above. pages.append(formatted) - await LinePaginator.paginate(pages, self.context, embed=embed, max_lines=1, max_size=2040, cleanup=True) + await LinePaginator.paginate(pages, self.context, embed=embed, max_lines=1, max_size=2040) class Help(Cog): diff --git a/bot/pagination.py b/bot/pagination.py index f2cf192c4..bb49ead5e 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -1,9 +1,8 @@ import asyncio import logging -from contextlib import suppress from typing import Iterable, List, Optional, Tuple -from discord import Embed, HTTPException, Member, Message, Reaction +from discord import Embed, Member, Message, Reaction from discord.abc import User from discord.ext.commands import Context, Paginator @@ -101,8 +100,6 @@ class LinePaginator(Paginator): footer_text: str = None, url: str = None, exception_on_empty_embed: bool = False, - description: str = '', - cleanup: bool = False ) -> Optional[Message]: """ Use a paginator and set of reactions to provide pagination over a set of lines. @@ -114,9 +111,6 @@ class LinePaginator(Paginator): Pagination will also be removed automatically if no reaction is added for five minutes (300 seconds). - The description is a string that should appear at the top of every page. - If cleanup is True, the paginated message will be deleted when :x: reaction is added. - Example: >>> embed = Embed() >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) @@ -167,7 +161,7 @@ class LinePaginator(Paginator): log.debug(f"Paginator created with {len(paginator.pages)} pages") - embed.description = description + paginator.pages[current_page] + embed.description = paginator.pages[current_page] if len(paginator.pages) <= 1: if footer_text: @@ -211,11 +205,6 @@ class LinePaginator(Paginator): if reaction.emoji == DELETE_EMOJI: log.debug("Got delete reaction") - if cleanup: - with suppress(HTTPException, AttributeError): - log.debug("Deleting help message") - await message.delete() - return break if reaction.emoji == FIRST_EMOJI: @@ -226,7 +215,7 @@ class LinePaginator(Paginator): embed.description = "" await message.edit(embed=embed) - embed.description = description + paginator.pages[current_page] + embed.description = paginator.pages[current_page] if footer_text: embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") else: @@ -241,7 +230,7 @@ class LinePaginator(Paginator): embed.description = "" await message.edit(embed=embed) - embed.description = description + paginator.pages[current_page] + embed.description = paginator.pages[current_page] if footer_text: embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") else: @@ -260,7 +249,7 @@ class LinePaginator(Paginator): embed.description = "" await message.edit(embed=embed) - embed.description = description + paginator.pages[current_page] + embed.description = paginator.pages[current_page] if footer_text: embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") @@ -281,7 +270,7 @@ class LinePaginator(Paginator): embed.description = "" await message.edit(embed=embed) - embed.description = description + paginator.pages[current_page] + embed.description = paginator.pages[current_page] if footer_text: embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") -- cgit v1.2.3 From 5f896ce4bdb78039624af4a10e16e68bbfff277d Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sat, 16 Nov 2019 21:13:52 +1100 Subject: Remove trailing comma --- bot/pagination.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/pagination.py b/bot/pagination.py index bb49ead5e..76082f459 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -99,7 +99,7 @@ class LinePaginator(Paginator): timeout: int = 300, footer_text: str = None, url: str = None, - exception_on_empty_embed: bool = False, + exception_on_empty_embed: bool = False ) -> Optional[Message]: """ Use a paginator and set of reactions to provide pagination over a set of lines. -- cgit v1.2.3 From a590fdb5ab946daa9633a8a1513749f6ae399570 Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Mon, 10 Feb 2020 22:01:57 +1100 Subject: Use the new :trashcan: emoji to delete the help message, as per #625 --- bot/cogs/help.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 76d584d64..e002192ff 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -9,13 +9,14 @@ from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand from fuzzywuzzy import fuzz, process from bot import constants -from bot.constants import Channels, STAFF_ROLES +from bot.constants import Channels, Emojis, STAFF_ROLES from bot.decorators import redirect_output -from bot.pagination import DELETE_EMOJI, LinePaginator +from bot.pagination import LinePaginator log = logging.getLogger(__name__) COMMANDS_PER_PAGE = 8 +DELETE_EMOJI = Emojis.trashcan PREFIX = constants.Bot.prefix Category = namedtuple("Category", ["name", "description", "cogs"]) @@ -25,11 +26,11 @@ async def help_cleanup(bot: Bot, author: Member, message: Message) -> None: """ Runs the cleanup for the help command. - Adds a :x: reaction that, when clicked, will delete the help message. + Adds the :trashcan: reaction that, when clicked, will delete the help message. After a 300 second timeout, the reaction will be removed. """ def check(r: Reaction, u: User) -> bool: - """Checks the reaction is :x:, the author is original author and messages are the same.""" + """Checks the reaction is :trashcan:, the author is original author and messages are the same.""" return str(r) == DELETE_EMOJI and u.id == author.id and r.message.id == message.id await message.add_reaction(DELETE_EMOJI) -- cgit v1.2.3 From 86ee960948d59fc0e4aa1b085633ba95ed5f788e Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Tue, 10 Mar 2020 19:27:12 +1100 Subject: Apply suggestions from Mark's code review. --- bot/cogs/help.py | 123 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 63 insertions(+), 60 deletions(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index e002192ff..334e7fc53 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -3,6 +3,7 @@ import logging from asyncio import TimeoutError from collections import namedtuple from contextlib import suppress +from typing import List from discord import Colour, Embed, HTTPException, Member, Message, Reaction, User from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand @@ -34,10 +35,13 @@ async def help_cleanup(bot: Bot, author: Member, message: Message) -> None: return str(r) == DELETE_EMOJI and u.id == author.id and r.message.id == message.id await message.add_reaction(DELETE_EMOJI) - with suppress(HTTPException, TimeoutError): - _, _ = await bot.wait_for("reaction_add", check=check, timeout=300) + + try: + await bot.wait_for("reaction_add", check=check, timeout=300) await message.delete() return + except (HTTPException, TimeoutError): + pass await message.remove_reaction(DELETE_EMOJI, bot.user) @@ -107,10 +111,12 @@ class CustomHelpCommand(HelpCommand): # it's either a cog, group, command or subcommand, let super deal with it await super().command_callback(ctx, command=command) - def get_all_help_choices(self) -> set: + async def get_all_help_choices(self) -> set: """ Get all the possible options for getting help in the bot. + This will only display commands the author has permission to run. + These include: - Category names - Cog names @@ -122,44 +128,42 @@ class CustomHelpCommand(HelpCommand): """ # first get all commands including subcommands and full command name aliases choices = set() - for c in self.context.bot.walk_commands(): + for c in await self.filter_commands(self.context.bot.walk_commands()): # the the command or group name choices.add(str(c)) - # all aliases if it's just a command if isinstance(c, Command): + # all aliases if it's just a command choices.update(c.aliases) + else: + # otherwise we need to add the parent name in + choices.update(f"{c.full_parent_name} {a}" for a in c.aliases) - # else aliases with parent if group. we need to strip() in case it's a Command and `full_parent` is None, - # otherwise we get 2 commands: ` help` and normal `help`. - # We could do case-by-case with f-string but this is the cleanest solution - choices.update(f"{c.full_parent_name} {a}".strip() for a in c.aliases) - - # all cog names + # all cog names choices.update(self.context.bot.cogs) # all category names choices.update(n.category for n in self.context.bot.cogs if hasattr(n, "category")) return choices - def command_not_found(self, string: str) -> "HelpQueryNotFound": + async def command_not_found(self, string: str) -> "HelpQueryNotFound": """ Handles when a query does not match a valid command, group, cog or category. Will return an instance of the `HelpQueryNotFound` exception with the error message and possible matches. """ - choices = self.get_all_help_choices() + choices = await self.get_all_help_choices() result = process.extractBests(string, choices, scorer=fuzz.ratio, score_cutoff=90) return HelpQueryNotFound(f'Query "{string}" not found.', dict(result)) - def subcommand_not_found(self, command: Command, string: str) -> "HelpQueryNotFound": + async def subcommand_not_found(self, command: Command, string: str) -> "HelpQueryNotFound": """ Redirects the error to `command_not_found`. `command_not_found` deals with searching and getting best choices for both commands and subcommands. """ - return self.command_not_found(f"{command.qualified_name} {string}") + return await self.command_not_found(f"{command.qualified_name} {string}") async def send_error_message(self, error: HelpQueryNotFound) -> None: """Send the error message to the channel.""" @@ -205,6 +209,18 @@ class CustomHelpCommand(HelpCommand): message = await self.context.send(embed=embed) await help_cleanup(self.context.bot, self.context.author, message) + @staticmethod + def get_commands_brief_details(commands_: List[Command]) -> str: + """Formats the prefix, command name and signature, and short doc for an iterable of commands.""" + details = "" + for c in commands_: + if c.signature: + details += f"\n**`{PREFIX}{c.qualified_name} {c.signature}`**\n*{c.short_doc or 'No details provided'}*" + else: + details += f"\n**`{PREFIX}{c.qualified_name}`**\n*{c.short_doc or 'No details provided.'}*" + + return details + async def send_group_help(self, group: Group) -> None: """Sends help for a group command.""" subcommands = group.commands @@ -215,19 +231,13 @@ class CustomHelpCommand(HelpCommand): return # remove commands that the user can't run and are hidden, and sort by name - _commands = await self.filter_commands(subcommands, sort=True) + commands_ = await self.filter_commands(subcommands, sort=True) embed = await self.command_formatting(group) - # add in subcommands with brief help - # note: the extra f-string around the signature is necessary because otherwise an extra space before the - # last back tick is present. - fmt = "\n".join( - f"**`{PREFIX}{c.qualified_name}{f' {c.signature}' if c.signature else ''}`**" - f"\n*{c.short_doc or 'No details provided.'}*" for c in _commands - ) - if fmt: - embed.description += f"\n**Subcommands:**\n{fmt}" + command_details = self.get_commands_brief_details(commands_) + if command_details: + embed.description += f"\n**Subcommands:**\n{command_details}" message = await self.context.send(embed=embed) await help_cleanup(self.context.bot, self.context.author, message) @@ -235,19 +245,15 @@ class CustomHelpCommand(HelpCommand): async def send_cog_help(self, cog: Cog) -> None: """Send help for a cog.""" # sort commands by name, and remove any the user cant run or are hidden. - _commands = await self.filter_commands(cog.get_commands(), sort=True) + commands_ = await self.filter_commands(cog.get_commands(), sort=True) embed = Embed() embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) embed.description = f"**{cog.qualified_name}**\n*{cog.description}*" - lines = [ - f"`{PREFIX}{c.qualified_name}{f' {c.signature}' if c.signature else ''}`" - f"\n*{c.short_doc or 'No details provided.'}*" for c in _commands - ] - if lines: - embed.description += "\n\n**Commands:**\n" - embed.description += "\n".join(n for n in lines) + command_details = self.get_commands_brief_details(commands_) + if command_details: + embed.description += f"\n\n**Commands:**\n{command_details}" message = await self.context.send(embed=embed) await help_cleanup(self.context.bot, self.context.author, message) @@ -280,20 +286,25 @@ class CustomHelpCommand(HelpCommand): for c in category.cogs: all_commands.extend(c.get_commands()) - filtered_commands = await self.filter_commands(all_commands, sort=True, key=self._category_key) + filtered_commands = await self.filter_commands(all_commands, sort=True) lines = [ f"`{PREFIX}{c.qualified_name}{f' {c.signature}' if c.signature else ''}`" f"\n*{c.short_doc or 'No details provided.'}*" for c in filtered_commands ] - description = f"```**{category.name}**\n*{category.description}*" + description = f"**{category.name}**\n*{category.description}*" if lines: description += "\n\n**Commands:**" await LinePaginator.paginate( - lines, self.context, embed, prefix=description, max_lines=COMMANDS_PER_PAGE, max_size=2040 + lines, + self.context, + embed, + prefix=description, + max_lines=COMMANDS_PER_PAGE, + max_size=2040 ) async def send_bot_help(self, mapping: dict) -> None: @@ -305,7 +316,7 @@ class CustomHelpCommand(HelpCommand): filter_commands = await self.filter_commands(bot.commands, sort=True, key=self._category_key) - lines = [] + cog_or_category_pages = [] for cog_or_category, _commands in itertools.groupby(filter_commands, key=self._category_key): sorted_commands = sorted(_commands, key=lambda c: c.name) @@ -313,43 +324,35 @@ class CustomHelpCommand(HelpCommand): if len(sorted_commands) == 0: continue - fmt = [ + command_details = [ f"`{PREFIX}{c.qualified_name}{f' {c.signature}' if c.signature else ''}`" f"\n*{c.short_doc or 'No details provided.'}*" for c in sorted_commands ] - # we can't embed a '\n'.join() inside an f-string so this is a bit of a compromise - def get_fmt(i: int) -> str: - """Get a formatted version of commands for an index.""" - return "\n".join(fmt[i:i+COMMANDS_PER_PAGE]) - - # this is a bit yuck because moderation category has 8 commands which needs to be split over 2 pages. - # pretty much it only splits that category, but also gives the number of commands it's adding to - # the pages every iteration so we can easily use this below rather than trying to split the string. - lines.extend( - ( - (f"**{cog_or_category}**\n{get_fmt(i)}", len(fmt[i:i+COMMANDS_PER_PAGE])) - for i in range(0, len(sorted_commands), COMMANDS_PER_PAGE) - ) - ) + # Split cogs or categories which have too many commands to fit in one page. + # The length of commands is included for later use when aggregating into pages for the paginator. + for i in range(0, len(sorted_commands), COMMANDS_PER_PAGE): + truncated_fmt = command_details[i:i + COMMANDS_PER_PAGE] + joined_fmt = "\n".join(truncated_fmt) + cog_or_category_pages.append((f"**{cog_or_category}**\n{joined_fmt}", len(truncated_fmt))) pages = [] counter = 0 - formatted = "" - for (fmt, length) in lines: + page = "" + for fmt, length in cog_or_category_pages: counter += length if counter > COMMANDS_PER_PAGE: # force a new page on paginator even if it falls short of the max pages # since we still want to group categories/cogs. counter = length - pages.append(formatted) - formatted = f"{fmt}\n\n" - continue - formatted += f"{fmt}\n\n" + pages.append(page) + page = f"{fmt}\n\n" + else: + page += f"{fmt}\n\n" - if formatted: + if page: # add any remaining command help that didn't get added in the last iteration above. - pages.append(formatted) + pages.append(page) await LinePaginator.paginate(pages, self.context, embed=embed, max_lines=1, max_size=2040) -- cgit v1.2.3 From 1f68c35a0343c99c1364eba58cafd43aefb0fabc Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Tue, 31 Mar 2020 20:05:28 +1100 Subject: Apply suggestions from review. - Make exception handling for bin reaction more specific - Channel constants were updated recently - Suggest category names - Tidy up signature formatting - Move score cutoff to 80 to allow a few more matches --- bot/cogs/help.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 334e7fc53..f020a1725 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -5,7 +5,7 @@ from collections import namedtuple from contextlib import suppress from typing import List -from discord import Colour, Embed, HTTPException, Member, Message, Reaction, User +from discord import Colour, Embed, NotFound, Member, Message, Reaction, User from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand from fuzzywuzzy import fuzz, process @@ -39,12 +39,11 @@ async def help_cleanup(bot: Bot, author: Member, message: Message) -> None: try: await bot.wait_for("reaction_add", check=check, timeout=300) await message.delete() - return - except (HTTPException, TimeoutError): + except TimeoutError: + await message.remove_reaction(DELETE_EMOJI, bot.user) + except NotFound: pass - await message.remove_reaction(DELETE_EMOJI, bot.user) - class HelpQueryNotFound(ValueError): """ @@ -75,7 +74,7 @@ class CustomHelpCommand(HelpCommand): def __init__(self): super().__init__(command_attrs={"help": "Shows help for bot commands"}) - @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES) + @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES) async def prepare_help_command(self, ctx: Context, command: str = None) -> None: """Adjust context to redirect to a new channel if required.""" self.context = ctx @@ -143,7 +142,7 @@ class CustomHelpCommand(HelpCommand): choices.update(self.context.bot.cogs) # all category names - choices.update(n.category for n in self.context.bot.cogs if hasattr(n, "category")) + choices.update(n.category for n in self.context.bot.cogs.values() if hasattr(n, "category")) return choices async def command_not_found(self, string: str) -> "HelpQueryNotFound": @@ -153,7 +152,7 @@ class CustomHelpCommand(HelpCommand): Will return an instance of the `HelpQueryNotFound` exception with the error message and possible matches. """ choices = await self.get_all_help_choices() - result = process.extractBests(string, choices, scorer=fuzz.ratio, score_cutoff=90) + result = process.extractBests(string, choices, scorer=fuzz.ratio, score_cutoff=80) return HelpQueryNotFound(f'Query "{string}" not found.', dict(result)) @@ -214,10 +213,8 @@ class CustomHelpCommand(HelpCommand): """Formats the prefix, command name and signature, and short doc for an iterable of commands.""" details = "" for c in commands_: - if c.signature: - details += f"\n**`{PREFIX}{c.qualified_name} {c.signature}`**\n*{c.short_doc or 'No details provided'}*" - else: - details += f"\n**`{PREFIX}{c.qualified_name}`**\n*{c.short_doc or 'No details provided.'}*" + signature = f" {c.signature}" if c.signature else "" + details += f"\n**`{PREFIX}{c.qualified_name}{signature}`**\n*{c.short_doc or 'No details provided'}*" return details -- cgit v1.2.3 From 72972704559fb9c5b86b6af32cb0d1799be4e563 Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Tue, 31 Mar 2020 20:21:22 +1100 Subject: Fix linting? Not sure why my precommit didn't pick that up... --- bot/cogs/help.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index f020a1725..74c4cc664 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -5,7 +5,7 @@ from collections import namedtuple from contextlib import suppress from typing import List -from discord import Colour, Embed, NotFound, Member, Message, Reaction, User +from discord import Colour, Embed, Member, Message, NotFound, Reaction, User from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand from fuzzywuzzy import fuzz, process -- cgit v1.2.3 From 2559bc90517bfbd76f1a57a7a6c0dc75660c1985 Mon Sep 17 00:00:00 2001 From: decorator-factory <42166884+decorator-factory@users.noreply.github.com> Date: Wed, 1 Apr 2020 00:28:43 +0300 Subject: Add mutability.md tag --- bot/resources/tags/mutability.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 bot/resources/tags/mutability.md diff --git a/bot/resources/tags/mutability.md b/bot/resources/tags/mutability.md new file mode 100644 index 000000000..08e28b370 --- /dev/null +++ b/bot/resources/tags/mutability.md @@ -0,0 +1,34 @@ +# Mutable vs immutable objects + +Imagine that you want to make all letters in a string upper case. +Conveniently, strings have an `.upper()` method. + +You might think that this would work: +```python +string = "abcd" +string.upper() +print(string) # abcd +``` + +`string` didn't change. Why is that so? + +That's because strings in Python are _immutable_. You can't change them, you can only pass +around existing strings or create new ones. + +```python +string = "abcd" +string = string.upper() +``` +`string.upper()` creates a new string which is like the old one, but with all +the letters turned to upper case. + +`int`, `float`, `complex`, `tuple`, `frozenset` are other examples of immutable data types in Python. + +Mutable data types like `list`, on the other hand, can be changed in-place: +```python +my_list = [1, 2, 3] +my_list.append(4) +print(my_list) # [1, 2, 3, 4] +``` + +`dict` and `set` are other examples of mutable data types in Python. -- cgit v1.2.3 From 028781aa7cb4bce036c4cc49c636e8adf3bac0df Mon Sep 17 00:00:00 2001 From: decorator-factory <42166884+decorator-factory@users.noreply.github.com> Date: Wed, 1 Apr 2020 00:33:06 +0300 Subject: header->bold in mutability.md --- bot/resources/tags/mutability.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/mutability.md b/bot/resources/tags/mutability.md index 08e28b370..fc9e5374d 100644 --- a/bot/resources/tags/mutability.md +++ b/bot/resources/tags/mutability.md @@ -1,4 +1,4 @@ -# Mutable vs immutable objects +**Mutable vs immutable objects** Imagine that you want to make all letters in a string upper case. Conveniently, strings have an `.upper()` method. -- cgit v1.2.3 From a9468420065a523dc3d9d44fd4bbebefac82544f Mon Sep 17 00:00:00 2001 From: decorator-factory <42166884+decorator-factory@users.noreply.github.com> Date: Wed, 1 Apr 2020 12:54:13 +0300 Subject: Fix hard-wrapping in mutability.md --- bot/resources/tags/mutability.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/bot/resources/tags/mutability.md b/bot/resources/tags/mutability.md index fc9e5374d..48e5bac74 100644 --- a/bot/resources/tags/mutability.md +++ b/bot/resources/tags/mutability.md @@ -1,7 +1,6 @@ **Mutable vs immutable objects** -Imagine that you want to make all letters in a string upper case. -Conveniently, strings have an `.upper()` method. +Imagine that you want to make all letters in a string upper case. Conveniently, strings have an `.upper()` method. You might think that this would work: ```python @@ -12,17 +11,16 @@ print(string) # abcd `string` didn't change. Why is that so? -That's because strings in Python are _immutable_. You can't change them, you can only pass -around existing strings or create new ones. +That's because strings in Python are _immutable_. You can't change them, you can only pass around existing strings or create new oness. ```python string = "abcd" string = string.upper() ``` -`string.upper()` creates a new string which is like the old one, but with all -the letters turned to upper case. -`int`, `float`, `complex`, `tuple`, `frozenset` are other examples of immutable data types in Python. +`string.upper()` creates a new string which is like the old one, but with allthe letters turned to upper case. + +`int`, `float`, `complex`, `tuple`, `frozenset` are other examples of immutable data types in Python. Mutable data types like `list`, on the other hand, can be changed in-place: ```python -- cgit v1.2.3 From 7a02ea8d35628e92ede20f6f1b93418a105040c2 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 1 Apr 2020 10:46:33 -0700 Subject: Help: lower score cutoff for fuzzy match --- bot/cogs/help.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 74c4cc664..6fc4d83f8 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -152,7 +152,7 @@ class CustomHelpCommand(HelpCommand): Will return an instance of the `HelpQueryNotFound` exception with the error message and possible matches. """ choices = await self.get_all_help_choices() - result = process.extractBests(string, choices, scorer=fuzz.ratio, score_cutoff=80) + result = process.extractBests(string, choices, scorer=fuzz.ratio, score_cutoff=60) return HelpQueryNotFound(f'Query "{string}" not found.', dict(result)) -- cgit v1.2.3 From ecb777b167dce5a8246e9c5ad8a202b370d97b7d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 19 Apr 2020 20:09:12 +0300 Subject: Created `News` cog Added general content of cog: class and setup. --- bot/cogs/news.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 bot/cogs/news.py diff --git a/bot/cogs/news.py b/bot/cogs/news.py new file mode 100644 index 000000000..8eb8689c2 --- /dev/null +++ b/bot/cogs/news.py @@ -0,0 +1,15 @@ +from discord.ext.commands import Cog + +from bot.bot import Bot + + +class News(Cog): + """Post new PEPs and Python News to `#python-news`.""" + + def __init__(self, bot: Bot): + self.bot = bot + + +def setup(bot: Bot) -> None: + """Add `News` cog.""" + bot.add_cog(News(bot)) -- cgit v1.2.3 From 9e586ef21170953a4879ca038bbc15e354937ddb Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 20 Apr 2020 08:29:07 +0300 Subject: Added #python-news channel ID to constants `Channels` --- bot/constants.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/constants.py b/bot/constants.py index 2add028e7..8135f47a9 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -394,6 +394,7 @@ class Channels(metaclass=YAMLGetter): off_topic_2: int organisation: int python_discussion: int + python_news: int reddit: int talent_pool: int user_event_announcements: int -- cgit v1.2.3 From b99a767b8bde01c730fec0ceb1ddf6fdb31bb983 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 20 Apr 2020 11:37:04 +0300 Subject: Added `News` cog loading --- bot/__main__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/__main__.py b/bot/__main__.py index 3aa36bfc0..42c1a4f3a 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -51,6 +51,7 @@ bot.load_extension("bot.cogs.eval") bot.load_extension("bot.cogs.information") bot.load_extension("bot.cogs.jams") bot.load_extension("bot.cogs.moderation") +bot.load_extension("bot.cogs.news") bot.load_extension("bot.cogs.off_topic_names") bot.load_extension("bot.cogs.reddit") bot.load_extension("bot.cogs.reminders") -- cgit v1.2.3 From bb48c5e6fea14bc8ec42b1188ceb5008fa259463 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 20 Apr 2020 11:46:17 +0300 Subject: Added helper function `News.sync_maillists` Function sync maillists listing with API, that hold IDs of message that have news. PEPs handling is over RSS, so this will added manually in this function. --- bot/cogs/news.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/bot/cogs/news.py b/bot/cogs/news.py index 8eb8689c2..c850b4192 100644 --- a/bot/cogs/news.py +++ b/bot/cogs/news.py @@ -2,12 +2,35 @@ from discord.ext.commands import Cog from bot.bot import Bot +MAIL_LISTS = [ + "python-ideas", + "python-announce-list", + "pypi-announce" +] + class News(Cog): """Post new PEPs and Python News to `#python-news`.""" def __init__(self, bot: Bot): self.bot = bot + self.bot.loop.create_task(self.sync_maillists()) + + async def sync_maillists(self) -> None: + """Sync currently in-use maillists with API.""" + # Wait until guild is available to avoid running before API is ready + await self.bot.wait_until_guild_available() + + response = await self.bot.api_client.get("bot/bot-settings/news") + for mail in MAIL_LISTS: + if mail not in response["data"]: + response["data"][mail] = [] + + # Because we are handling PEPs differently, we don't include it to mail lists + if "pep" not in response["data"]: + response["data"]["pep"] = [] + + await self.bot.api_client.put("bot/bot-settings/news", json=response) def setup(bot: Bot) -> None: -- cgit v1.2.3 From b6450b57207341d5cf9b581b0e56a579a154cae4 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 20 Apr 2020 12:01:27 +0300 Subject: Added new dependency `feedparser` --- Pipfile | 1 + Pipfile.lock | 105 ++++++++++++++++++++++++++++++++--------------------------- 2 files changed, 58 insertions(+), 48 deletions(-) diff --git a/Pipfile b/Pipfile index e7fb61957..9994f58e9 100644 --- a/Pipfile +++ b/Pipfile @@ -21,6 +21,7 @@ sentry-sdk = "~=0.14" coloredlogs = "~=14.0" colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"} statsd = "~=3.3" +feedparser = "~=5.2" [dev-packages] coverage = "~=5.0" diff --git a/Pipfile.lock b/Pipfile.lock index 19e03bda4..5aae9e1b6 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "10636aef5a07f17bd00608df2cc5214fcbfe3de4745cdeea7a076b871754620a" + "sha256": "6a53e10f1f1bf5348da7675113ca2be2667960b7ba65630650e54e7d920d9269" }, "pipfile-spec": 6, "requires": { @@ -179,6 +179,15 @@ ], "version": "==0.16" }, + "feedparser": { + "hashes": [ + "sha256:bd030652c2d08532c034c27fcd7c85868e7fa3cb2b17f230a44a6bbc92519bf9", + "sha256:cd2485472e41471632ed3029d44033ee420ad0b57111db95c240c9160a85831c", + "sha256:ce875495c90ebd74b179855449040003a1beb40cd13d5f037a0654251e260b02" + ], + "index": "pypi", + "version": "==5.2.1" + }, "fuzzywuzzy": { "hashes": [ "sha256:45016e92264780e58972dca1b3d939ac864b78437422beecebb3095f8efd00e8", @@ -189,10 +198,10 @@ }, "humanfriendly": { "hashes": [ - "sha256:25c2108a45cfd1e8fbe9cdb30b825d34ef5d5675c8e11e4775c9aedbfb0bdee2", - "sha256:3a831920e40e55ad49adb64c9179ed50c604cabca72cd300e7bd5b51310e4ebb" + "sha256:bf52ec91244819c780341a3438d5d7b09f431d3f113a475147ac9b7b167a3d12", + "sha256:e78960b31198511f45fd455534ae7645a6207d33e512d2e842c766d15d9c8080" ], - "version": "==8.1" + "version": "==8.2" }, "idna": { "hashes": [ @@ -210,10 +219,10 @@ }, "jinja2": { "hashes": [ - "sha256:93187ffbc7808079673ef52771baa950426fd664d3aad1d0fa3e95644360e250", - "sha256:b0eaf100007721b5c16c1fc1eecb87409464edc10469ddc9a22a27a99123be49" + "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", + "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" ], - "version": "==2.11.1" + "version": "==2.11.2" }, "lxml": { "hashes": [ @@ -527,10 +536,10 @@ }, "urllib3": { "hashes": [ - "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", - "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" + "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", + "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" ], - "version": "==1.25.8" + "version": "==1.25.9" }, "websockets": { "hashes": [ @@ -606,40 +615,40 @@ }, "coverage": { "hashes": [ - "sha256:03f630aba2b9b0d69871c2e8d23a69b7fe94a1e2f5f10df5049c0df99db639a0", - "sha256:046a1a742e66d065d16fb564a26c2a15867f17695e7f3d358d7b1ad8a61bca30", - "sha256:0a907199566269e1cfa304325cc3b45c72ae341fbb3253ddde19fa820ded7a8b", - "sha256:165a48268bfb5a77e2d9dbb80de7ea917332a79c7adb747bd005b3a07ff8caf0", - "sha256:1b60a95fc995649464e0cd48cecc8288bac5f4198f21d04b8229dc4097d76823", - "sha256:1f66cf263ec77af5b8fe14ef14c5e46e2eb4a795ac495ad7c03adc72ae43fafe", - "sha256:2e08c32cbede4a29e2a701822291ae2bc9b5220a971bba9d1e7615312efd3037", - "sha256:3844c3dab800ca8536f75ae89f3cf566848a3eb2af4d9f7b1103b4f4f7a5dad6", - "sha256:408ce64078398b2ee2ec08199ea3fcf382828d2f8a19c5a5ba2946fe5ddc6c31", - "sha256:443be7602c790960b9514567917af538cac7807a7c0c0727c4d2bbd4014920fd", - "sha256:4482f69e0701139d0f2c44f3c395d1d1d37abd81bfafbf9b6efbe2542679d892", - "sha256:4a8a259bf990044351baf69d3b23e575699dd60b18460c71e81dc565f5819ac1", - "sha256:513e6526e0082c59a984448f4104c9bf346c2da9961779ede1fc458e8e8a1f78", - "sha256:5f587dfd83cb669933186661a351ad6fc7166273bc3e3a1531ec5c783d997aac", - "sha256:62061e87071497951155cbccee487980524d7abea647a1b2a6eb6b9647df9006", - "sha256:641e329e7f2c01531c45c687efcec8aeca2a78a4ff26d49184dce3d53fc35014", - "sha256:65a7e00c00472cd0f59ae09d2fb8a8aaae7f4a0cf54b2b74f3138d9f9ceb9cb2", - "sha256:6ad6ca45e9e92c05295f638e78cd42bfaaf8ee07878c9ed73e93190b26c125f7", - "sha256:73aa6e86034dad9f00f4bbf5a666a889d17d79db73bc5af04abd6c20a014d9c8", - "sha256:7c9762f80a25d8d0e4ab3cb1af5d9dffbddb3ee5d21c43e3474c84bf5ff941f7", - "sha256:85596aa5d9aac1bf39fe39d9fa1051b0f00823982a1de5766e35d495b4a36ca9", - "sha256:86a0ea78fd851b313b2e712266f663e13b6bc78c2fb260b079e8b67d970474b1", - "sha256:8a620767b8209f3446197c0e29ba895d75a1e272a36af0786ec70fe7834e4307", - "sha256:922fb9ef2c67c3ab20e22948dcfd783397e4c043a5c5fa5ff5e9df5529074b0a", - "sha256:9fad78c13e71546a76c2f8789623eec8e499f8d2d799f4b4547162ce0a4df435", - "sha256:a37c6233b28e5bc340054cf6170e7090a4e85069513320275a4dc929144dccf0", - "sha256:c3fc325ce4cbf902d05a80daa47b645d07e796a80682c1c5800d6ac5045193e5", - "sha256:cda33311cb9fb9323958a69499a667bd728a39a7aa4718d7622597a44c4f1441", - "sha256:db1d4e38c9b15be1521722e946ee24f6db95b189d1447fa9ff18dd16ba89f732", - "sha256:eda55e6e9ea258f5e4add23bcf33dc53b2c319e70806e180aecbff8d90ea24de", - "sha256:f372cdbb240e09ee855735b9d85e7f50730dcfb6296b74b95a3e5dea0615c4c1" + "sha256:00f1d23f4336efc3b311ed0d807feb45098fc86dee1ca13b3d6768cdab187c8a", + "sha256:01333e1bd22c59713ba8a79f088b3955946e293114479bbfc2e37d522be03355", + "sha256:0cb4be7e784dcdc050fc58ef05b71aa8e89b7e6636b99967fadbdba694cf2b65", + "sha256:0e61d9803d5851849c24f78227939c701ced6704f337cad0a91e0972c51c1ee7", + "sha256:1601e480b9b99697a570cea7ef749e88123c04b92d84cedaa01e117436b4a0a9", + "sha256:2742c7515b9eb368718cd091bad1a1b44135cc72468c731302b3d641895b83d1", + "sha256:2d27a3f742c98e5c6b461ee6ef7287400a1956c11421eb574d843d9ec1f772f0", + "sha256:402e1744733df483b93abbf209283898e9f0d67470707e3c7516d84f48524f55", + "sha256:5c542d1e62eece33c306d66fe0a5c4f7f7b3c08fecc46ead86d7916684b36d6c", + "sha256:5f2294dbf7875b991c381e3d5af2bcc3494d836affa52b809c91697449d0eda6", + "sha256:6402bd2fdedabbdb63a316308142597534ea8e1895f4e7d8bf7476c5e8751fef", + "sha256:66460ab1599d3cf894bb6baee8c684788819b71a5dc1e8fa2ecc152e5d752019", + "sha256:782caea581a6e9ff75eccda79287daefd1d2631cc09d642b6ee2d6da21fc0a4e", + "sha256:79a3cfd6346ce6c13145731d39db47b7a7b859c0272f02cdb89a3bdcbae233a0", + "sha256:7a5bdad4edec57b5fb8dae7d3ee58622d626fd3a0be0dfceda162a7035885ecf", + "sha256:8fa0cbc7ecad630e5b0f4f35b0f6ad419246b02bc750de7ac66db92667996d24", + "sha256:a027ef0492ede1e03a8054e3c37b8def89a1e3c471482e9f046906ba4f2aafd2", + "sha256:a3f3654d5734a3ece152636aad89f58afc9213c6520062db3978239db122f03c", + "sha256:a82b92b04a23d3c8a581fc049228bafde988abacba397d57ce95fe95e0338ab4", + "sha256:acf3763ed01af8410fc36afea23707d4ea58ba7e86a8ee915dfb9ceff9ef69d0", + "sha256:adeb4c5b608574a3d647011af36f7586811a2c1197c861aedb548dd2453b41cd", + "sha256:b83835506dfc185a319031cf853fa4bb1b3974b1f913f5bb1a0f3d98bdcded04", + "sha256:bb28a7245de68bf29f6fb199545d072d1036a1917dca17a1e75bbb919e14ee8e", + "sha256:bf9cb9a9fd8891e7efd2d44deb24b86d647394b9705b744ff6f8261e6f29a730", + "sha256:c317eaf5ff46a34305b202e73404f55f7389ef834b8dbf4da09b9b9b37f76dd2", + "sha256:dbe8c6ae7534b5b024296464f387d57c13caa942f6d8e6e0346f27e509f0f768", + "sha256:de807ae933cfb7f0c7d9d981a053772452217df2bf38e7e6267c9cbf9545a796", + "sha256:dead2ddede4c7ba6cb3a721870f5141c97dc7d85a079edb4bd8d88c3ad5b20c7", + "sha256:dec5202bfe6f672d4511086e125db035a52b00f1648d6407cc8e526912c0353a", + "sha256:e1ea316102ea1e1770724db01998d1603ed921c54a86a2efcb03428d5417e489", + "sha256:f90bfc4ad18450c80b024036eaf91e4a246ae287701aaa88eaebebf150868052" ], "index": "pypi", - "version": "==5.0.4" + "version": "==5.1" }, "distlib": { "hashes": [ @@ -671,11 +680,11 @@ }, "flake8-annotations": { "hashes": [ - "sha256:a38b44d01abd480586a92a02a2b0a36231ec42dcc5e114de78fa5db016d8d3f9", - "sha256:d5b0e8704e4e7728b352fa1464e23539ff2341ba11cc153b536fa2cf921ee659" + "sha256:9091d920406a7ff10e401e0dd1baa396d1d7d2e3d101a9beecf815f5894ad554", + "sha256:f59fdceb8c8f380a20aed20e1ba8a57bde05935958166c52be2249f113f7ab75" ], "index": "pypi", - "version": "==2.0.1" + "version": "==2.1.0" }, "flake8-bugbear": { "hashes": [ @@ -836,10 +845,10 @@ }, "virtualenv": { "hashes": [ - "sha256:00cfe8605fb97f5a59d52baab78e6070e72c12ca64f51151695407cc0eb8a431", - "sha256:c8364ec469084046c779c9a11ae6340094e8a0bf1d844330fc55c1cefe67c172" + "sha256:5021396e8f03d0d002a770da90e31e61159684db2859d0ba4850fbea752aa675", + "sha256:ac53ade75ca189bc97b6c1d9ec0f1a50efe33cbf178ae09452dcd9fd309013c1" ], - "version": "==20.0.17" + "version": "==20.0.18" } } } -- cgit v1.2.3 From ce7efd3c27ea706cb46055c7eae06b52ffce7491 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 20 Apr 2020 12:51:03 +0300 Subject: Added #python-news channel webhook to `Webhooks` in constants --- bot/constants.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/constants.py b/bot/constants.py index 8135f47a9..4c2f22741 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -412,6 +412,7 @@ class Webhooks(metaclass=YAMLGetter): reddit: int duck_pond: int dev_log: int + python_news: int class Roles(metaclass=YAMLGetter): -- cgit v1.2.3 From ab496d4b059673d9a8f3816119ad5fe37e2787cc Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 20 Apr 2020 12:59:35 +0300 Subject: Created helper function `get_webhook` and added property in `News` `News.get_webhook` fetch discord.Webhook by ID provided in config. `self.webhook` use webhook that it got from this function. --- bot/cogs/news.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/bot/cogs/news.py b/bot/cogs/news.py index c850b4192..69305c93d 100644 --- a/bot/cogs/news.py +++ b/bot/cogs/news.py @@ -1,5 +1,7 @@ +import discord from discord.ext.commands import Cog +from bot import constants from bot.bot import Bot MAIL_LISTS = [ @@ -15,10 +17,11 @@ class News(Cog): def __init__(self, bot: Bot): self.bot = bot self.bot.loop.create_task(self.sync_maillists()) + self.webhook = self.bot.loop.create_task(self.get_webhook()) async def sync_maillists(self) -> None: """Sync currently in-use maillists with API.""" - # Wait until guild is available to avoid running before API is ready + # Wait until guild is available to avoid running before everything is ready await self.bot.wait_until_guild_available() response = await self.bot.api_client.get("bot/bot-settings/news") @@ -32,6 +35,10 @@ class News(Cog): await self.bot.api_client.put("bot/bot-settings/news", json=response) + async def get_webhook(self) -> discord.Webhook: + """Get #python-news channel webhook.""" + return await self.bot.fetch_webhook(constants.Webhooks.python_news) + def setup(bot: Bot) -> None: """Add `News` cog.""" -- cgit v1.2.3 From e5f30076304eac16d76b0daabead346253c7d9b4 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 20 Apr 2020 13:08:43 +0300 Subject: Added new category `python_news` to config, that hold mail lists, channel and webhook. This use local dev environment IDs. --- config-default.yml | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/config-default.yml b/config-default.yml index f2b0bfa9f..553afaa33 100644 --- a/config-default.yml +++ b/config-default.yml @@ -122,6 +122,7 @@ guild: channels: announcements: 354619224620138496 user_event_announcements: &USER_EVENT_A 592000283102674944 + python_news: &PYNEWS_CHANNEL 701667765102051398 # Development dev_contrib: &DEV_CONTRIB 635950537262759947 @@ -231,11 +232,12 @@ guild: - *HELPERS_ROLE webhooks: - talent_pool: 569145364800602132 - big_brother: 569133704568373283 - reddit: 635408384794951680 - duck_pond: 637821475327311927 - dev_log: 680501655111729222 + talent_pool: 569145364800602132 + big_brother: 569133704568373283 + reddit: 635408384794951680 + duck_pond: 637821475327311927 + dev_log: 680501655111729222 + python_news: &PYNEWS_WEBHOOK 701731296342179850 filter: @@ -568,5 +570,13 @@ duck_pond: - *DUCKY_MAUL - *DUCKY_SANTA +python_news: + mail_lists: + - 'python-ideas' + - 'python-announce-list' + - 'pypi-announce' + channel: *PYNEWS_CHANNEL + webhook: *PYNEWS_WEBHOOK + config: required_keys: ['bot.token'] -- cgit v1.2.3 From f9dac725a5cac4dbe725aa86d1fbcee5e3a9b5af Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 20 Apr 2020 13:14:46 +0300 Subject: Applied Python News config changes Removed Webhook and Channel from their listings, created new class `PythonNews` that hold them + mail lists. --- bot/constants.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 4c2f22741..202a17d71 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -394,7 +394,6 @@ class Channels(metaclass=YAMLGetter): off_topic_2: int organisation: int python_discussion: int - python_news: int reddit: int talent_pool: int user_event_announcements: int @@ -412,7 +411,6 @@ class Webhooks(metaclass=YAMLGetter): reddit: int duck_pond: int dev_log: int - python_news: int class Roles(metaclass=YAMLGetter): @@ -571,6 +569,14 @@ class Sync(metaclass=YAMLGetter): max_diff: int +class PythonNews(metaclass=YAMLGetter): + section = 'python_news' + + mail_lists: List[str] + channel: int + webhook: int + + class Event(Enum): """ Event names. This does not include every event (for example, raw -- cgit v1.2.3 From 6abfc324bb81f7eb7224da913a62ae28cc49f674 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 20 Apr 2020 13:16:57 +0300 Subject: Applied constant changes to News Replaced in-file mail lists with constants.py's, replaced webhook ID getting. --- bot/cogs/news.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/bot/cogs/news.py b/bot/cogs/news.py index 69305c93d..3aa57442a 100644 --- a/bot/cogs/news.py +++ b/bot/cogs/news.py @@ -4,12 +4,6 @@ from discord.ext.commands import Cog from bot import constants from bot.bot import Bot -MAIL_LISTS = [ - "python-ideas", - "python-announce-list", - "pypi-announce" -] - class News(Cog): """Post new PEPs and Python News to `#python-news`.""" @@ -25,7 +19,7 @@ class News(Cog): await self.bot.wait_until_guild_available() response = await self.bot.api_client.get("bot/bot-settings/news") - for mail in MAIL_LISTS: + for mail in constants.PythonNews.mail_lists: if mail not in response["data"]: response["data"][mail] = [] @@ -37,7 +31,7 @@ class News(Cog): async def get_webhook(self) -> discord.Webhook: """Get #python-news channel webhook.""" - return await self.bot.fetch_webhook(constants.Webhooks.python_news) + return await self.bot.fetch_webhook(constants.PythonNews.webhook) def setup(bot: Bot) -> None: -- cgit v1.2.3 From c8c30f3df673975f6d22a14c4658598921c15254 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 20 Apr 2020 14:17:52 +0300 Subject: Created PEP news task + minor changes in `News` - Created task `post_pep_news` that pull existing news message IDs from API, do checks and send new PEP when it's not already sent. - Removed `get_webhook` - Removed `self.webhook` --- bot/cogs/news.py | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/bot/cogs/news.py b/bot/cogs/news.py index 3aa57442a..6e9441997 100644 --- a/bot/cogs/news.py +++ b/bot/cogs/news.py @@ -1,9 +1,18 @@ +import logging +from datetime import datetime + import discord +import feedparser from discord.ext.commands import Cog +from discord.ext.tasks import loop from bot import constants from bot.bot import Bot +PEPS_RSS_URL = "https://www.python.org/dev/peps/peps.rss/" + +log = logging.getLogger(__name__) + class News(Cog): """Post new PEPs and Python News to `#python-news`.""" @@ -11,7 +20,8 @@ class News(Cog): def __init__(self, bot: Bot): self.bot = bot self.bot.loop.create_task(self.sync_maillists()) - self.webhook = self.bot.loop.create_task(self.get_webhook()) + + self.post_pep_news.start() async def sync_maillists(self) -> None: """Sync currently in-use maillists with API.""" @@ -29,9 +39,64 @@ class News(Cog): await self.bot.api_client.put("bot/bot-settings/news", json=response) - async def get_webhook(self) -> discord.Webhook: - """Get #python-news channel webhook.""" - return await self.bot.fetch_webhook(constants.PythonNews.webhook) + @loop(minutes=20) + async def post_pep_news(self) -> None: + """Fetch new PEPs and when they don't have announcement in #python-news, create it.""" + # Wait until everything is ready and http_session available + await self.bot.wait_until_guild_available() + + async with self.bot.http_session.get(PEPS_RSS_URL) as resp: + data = feedparser.parse(await resp.text()) + + news_channel = self.bot.get_channel(constants.PythonNews.channel) + webhook = await self.bot.fetch_webhook(constants.PythonNews.webhook) + + news_listing = await self.bot.api_client.get("bot/bot-settings/news") + payload = news_listing.copy() + pep_news_ids = news_listing["data"]["pep"] + pep_news = [] + + for pep_id in pep_news_ids: + message = discord.utils.get(self.bot.cached_messages, id=pep_id) + if message is None: + message = await news_channel.fetch_message(pep_id) + if message is None: + log.warning(f"Can't fetch news message with ID {pep_id}. Deleting it entry from DB.") + payload["data"]["pep"].remove(pep_id) + pep_news.append((message.embeds[0].title, message.embeds[0].timestamp)) + + # Reverse entries to send oldest first + data["entries"].reverse() + for new in data["entries"]: + try: + new_datetime = datetime.strptime(new["published"], "%a, %d %b %Y %X %Z") + except ValueError: + log.warning(f"Wrong datetime format passed in PEP new: {new['published']}") + continue + if ( + any(pep_new[0] == new["title"] for pep_new in pep_news) + and any(pep_new[1] == new_datetime for pep_new in pep_news) + ): + continue + + embed = discord.Embed( + title=new["title"], + description=new["summary"], + timestamp=new_datetime, + url=new["link"], + colour=constants.Colours.soft_green + ) + + pep_msg = await webhook.send( + embed=embed, + username=data["feed"]["title"], + avatar_url="https://www.python.org/static/opengraph-icon-200x200.png", + wait=True + ) + payload["data"]["pep"].append(pep_msg.id) + + # Apply new sent news to DB to avoid duplicate sending + await self.bot.api_client.put("bot/bot-settings/news", json=payload) def setup(bot: Bot) -> None: -- cgit v1.2.3 From d3a1e346ab65f65e8addda68a2e5dc6860739448 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 20 Apr 2020 15:10:13 +0300 Subject: Added new function `News.get_webhook_names` + new variable `News.webhook_names` Function fetch display names of these mail lists, that bot will post. These names will be used on Webhook author names. `News.webhook_names` storage these name and display name pairs. --- bot/cogs/news.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/bot/cogs/news.py b/bot/cogs/news.py index 6e9441997..878e533ef 100644 --- a/bot/cogs/news.py +++ b/bot/cogs/news.py @@ -19,7 +19,9 @@ class News(Cog): def __init__(self, bot: Bot): self.bot = bot + self.webhook_names = {} self.bot.loop.create_task(self.sync_maillists()) + self.bot.loop.create_task(self.get_webhook_names()) self.post_pep_news.start() @@ -39,6 +41,17 @@ class News(Cog): await self.bot.api_client.put("bot/bot-settings/news", json=response) + async def get_webhook_names(self) -> None: + """Get webhook author names from maillist API.""" + await self.bot.wait_until_guild_available() + + async with self.bot.http_session.get("https://mail.python.org/archives/api/lists") as resp: + lists = await resp.json() + + for mail in lists: + if mail["name"].split("@")[0] in constants.PythonNews.mail_lists: + self.webhook_names[mail["name"].split("@")[0]] = mail["display_name"] + @loop(minutes=20) async def post_pep_news(self) -> None: """Fetch new PEPs and when they don't have announcement in #python-news, create it.""" -- cgit v1.2.3 From c2eac1f8b2a424dd018909b0e4084e730e029210 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 20 Apr 2020 15:19:46 +0300 Subject: Added new dependency `beatifulsoup4` for Python news HTML parsing --- Pipfile | 1 + Pipfile.lock | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Pipfile b/Pipfile index 9994f58e9..14c9ef926 100644 --- a/Pipfile +++ b/Pipfile @@ -22,6 +22,7 @@ coloredlogs = "~=14.0" colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"} statsd = "~=3.3" feedparser = "~=5.2" +beautifulsoup4 = "~=4.9" [dev-packages] coverage = "~=5.0" diff --git a/Pipfile.lock b/Pipfile.lock index 5aae9e1b6..4e7050a13 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "6a53e10f1f1bf5348da7675113ca2be2667960b7ba65630650e54e7d920d9269" + "sha256": "64620e7e825c74fd3010821fb30843b19f5dafb2b5a1f6eafedc0a5febd99b69" }, "pipfile-spec": 6, "requires": { @@ -91,6 +91,7 @@ "sha256:a4bbe77fd30670455c5296242967a123ec28c37e9702a8a81bd2f20a4baf0368", "sha256:d4e96ac9b0c3a6d3f0caae2e4124e6055c5dcafde8e2f831ff194c104f0775a0" ], + "index": "pypi", "version": "==4.9.0" }, "certifi": { -- cgit v1.2.3 From 866240620827623e9a9a813a38e2ad097fc1a783 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 20 Apr 2020 20:06:57 +0300 Subject: Defined `chardet` log level to warning to avoid spam --- bot/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/__init__.py b/bot/__init__.py index 2dd4af225..344afdf15 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -58,4 +58,5 @@ coloredlogs.install(logger=root_log, stream=sys.stdout) logging.getLogger("discord").setLevel(logging.WARNING) logging.getLogger("websockets").setLevel(logging.WARNING) +logging.getLogger("chardet").setLevel(logging.WARNING) logging.getLogger(__name__) -- cgit v1.2.3 From 7348afc0be7d6f5d3939b13c124e616051fc6170 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 20 Apr 2020 20:10:54 +0300 Subject: Implemented maillists news posting, created helper functions + added date check - Created helper function `News.get_thread_and_first_mail` - Created helper function `News.send_webhook` - Created helper function `News.check_new_exist` - Task `post_maillist_news`, that send latest maillist threads to news, when they don't exist. - Implemented helper functions to PEP news - Added date check --- bot/cogs/news.py | 150 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 131 insertions(+), 19 deletions(-) diff --git a/bot/cogs/news.py b/bot/cogs/news.py index 878e533ef..52c36da2e 100644 --- a/bot/cogs/news.py +++ b/bot/cogs/news.py @@ -1,8 +1,10 @@ import logging -from datetime import datetime +import typing as t +from datetime import date, datetime import discord import feedparser +from bs4 import BeautifulSoup from discord.ext.commands import Cog from discord.ext.tasks import loop @@ -11,6 +13,13 @@ from bot.bot import Bot PEPS_RSS_URL = "https://www.python.org/dev/peps/peps.rss/" +RECENT_THREADS_TEMPLATE = "https://mail.python.org/archives/list/{name}@python.org/recent-threads" +THREAD_TEMPLATE_URL = "https://mail.python.org/archives/api/list/{name}@python.org/thread/{id}/" +MAILMAN_PROFILE_URL = "https://mail.python.org/archives/users/{id}/" +THREAD_URL = "https://mail.python.org/archives/list/{list}@python.org/thread/{id}/" + +AVATAR_URL = "https://www.python.org/static/opengraph-icon-200x200.png" + log = logging.getLogger(__name__) @@ -24,6 +33,7 @@ class News(Cog): self.bot.loop.create_task(self.get_webhook_names()) self.post_pep_news.start() + self.post_maillist_news.start() async def sync_maillists(self) -> None: """Sync currently in-use maillists with API.""" @@ -74,8 +84,8 @@ class News(Cog): if message is None: message = await news_channel.fetch_message(pep_id) if message is None: - log.warning(f"Can't fetch news message with ID {pep_id}. Deleting it entry from DB.") - payload["data"]["pep"].remove(pep_id) + log.warning("Can't fetch PEP new message ID.") + continue pep_news.append((message.embeds[0].title, message.embeds[0].timestamp)) # Reverse entries to send oldest first @@ -87,30 +97,132 @@ class News(Cog): log.warning(f"Wrong datetime format passed in PEP new: {new['published']}") continue if ( - any(pep_new[0] == new["title"] for pep_new in pep_news) - and any(pep_new[1] == new_datetime for pep_new in pep_news) + (any(pep_new[0] == new["title"] for pep_new in pep_news) + and any(pep_new[1] == new_datetime for pep_new in pep_news)) + or new_datetime.date() < date.today() ): continue - embed = discord.Embed( - title=new["title"], - description=new["summary"], - timestamp=new_datetime, - url=new["link"], - colour=constants.Colours.soft_green - ) - - pep_msg = await webhook.send( - embed=embed, - username=data["feed"]["title"], - avatar_url="https://www.python.org/static/opengraph-icon-200x200.png", - wait=True + msg_id = await self.send_webhook( + webhook, + new["title"], + new["summary"], + new_datetime, + new["link"], + None, + None, + data["feed"]["title"] ) - payload["data"]["pep"].append(pep_msg.id) + payload["data"]["pep"].append(msg_id) # Apply new sent news to DB to avoid duplicate sending await self.bot.api_client.put("bot/bot-settings/news", json=payload) + @loop(minutes=20) + async def post_maillist_news(self) -> None: + """Send new maillist threads to #python-news that is listed in configuration.""" + await self.bot.wait_until_guild_available() + webhook = await self.bot.fetch_webhook(constants.PythonNews.webhook) + existing_news = await self.bot.api_client.get("bot/bot-settings/news") + payload = existing_news.copy() + + for maillist in constants.PythonNews.mail_lists: + async with self.bot.http_session.get(RECENT_THREADS_TEMPLATE.format(name=maillist)) as resp: + recents = BeautifulSoup(await resp.text()) + + for thread in recents.html.body.div.find_all("a", href=True): + # We want only these threads that have identifiers + if "latest" in thread["href"]: + continue + + thread_information, email_information = await self.get_thread_and_first_mail( + maillist, thread["href"].split("/")[-2] + ) + + try: + new_date = datetime.strptime(email_information["date"], "%Y-%m-%dT%X%z") + except ValueError: + log.warning(f"Invalid datetime from Thread email: {email_information['date']}") + continue + + if ( + await self.check_new_exist(thread_information["subject"], new_date, maillist, existing_news) + or new_date.date() < date.today() + ): + continue + + content = email_information["content"] + link = THREAD_URL.format(id=thread["href"].split("/")[-2], list=maillist) + msg_id = await self.send_webhook( + webhook, + thread_information["subject"], + content[:500] + f"... [continue reading]({link})" if len(content) > 500 else content, + new_date, + link, + f"{email_information['sender_name']} ({email_information['sender']['address']})", + MAILMAN_PROFILE_URL.format(id=email_information["sender"]["mailman_id"]), + self.webhook_names[maillist] + ) + payload["data"][maillist].append(msg_id) + + await self.bot.api_client.put("bot/bot-settings/news", json=payload) + + async def check_new_exist(self, title: str, timestamp: datetime, maillist: str, news: t.Dict[str, t.Any]) -> bool: + """Check does this new title + timestamp already exist in #python-news.""" + channel = await self.bot.fetch_channel(constants.PythonNews.channel) + + for new in news["data"][maillist]: + message = discord.utils.get(self.bot.cached_messages, id=new) + if message is None: + message = await channel.fetch_message(new) + if message is None: + return False + + if message.embeds[0].title == title and message.embeds[0].timestamp == timestamp: + return True + return False + + async def send_webhook(self, + webhook: discord.Webhook, + title: str, + description: str, + timestamp: datetime, + url: str, + author: str, + author_url: str, + webhook_profile_name: str + ) -> int: + """Send webhook entry and return ID of message.""" + embed = discord.Embed( + title=title, + description=description, + timestamp=timestamp, + url=url, + colour=constants.Colours.soft_green + ) + embed.set_author( + name=author, + url=author_url + ) + msg = await webhook.send( + embed=embed, + username=webhook_profile_name, + avatar_url=AVATAR_URL, + wait=True + ) + return msg.id + + async def get_thread_and_first_mail(self, maillist: str, thread_identifier: str) -> t.Tuple[t.Any, t.Any]: + """Get mail thread and first mail from mail.python.org based on `maillist` and `thread_identifier`.""" + async with self.bot.http_session.get( + THREAD_TEMPLATE_URL.format(name=maillist, id=thread_identifier) + ) as resp: + thread_information = await resp.json() + + async with self.bot.http_session.get(thread_information["starting_email"]) as resp: + email_information = await resp.json() + return thread_information, email_information + def setup(bot: Bot) -> None: """Add `News` cog.""" -- cgit v1.2.3 From 47fc4dbbcb288f5757b85ed1e0a385048b708c34 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 21 Apr 2020 08:30:41 +0300 Subject: `News` Cog improvisations - Created new helper function `News.get_webhook_and_channel` to will be run in Cog loading and will fetch #python-news channel and webhook. - Fixed `News.send_webhook` when you pass `None` as author, this will not add author. - Replaced individual channel and webhook fetches with `News.webhook` and `News.channel`. - Replaced positional arguments with kwargs in `send_webhook` uses. - Moved maillists syncing from `News.__init__` to `News.post_maillist_news`. - Simplified `News.post_pep_news` already exist checks. --- bot/cogs/news.py | 73 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 36 insertions(+), 37 deletions(-) diff --git a/bot/cogs/news.py b/bot/cogs/news.py index 52c36da2e..21ddb6128 100644 --- a/bot/cogs/news.py +++ b/bot/cogs/news.py @@ -29,8 +29,11 @@ class News(Cog): def __init__(self, bot: Bot): self.bot = bot self.webhook_names = {} - self.bot.loop.create_task(self.sync_maillists()) + self.webhook: t.Optional[discord.Webhook] = None + self.channel: t.Optional[discord.TextChannel] = None + self.bot.loop.create_task(self.get_webhook_names()) + self.bot.loop.create_task(self.get_webhook_and_channel()) self.post_pep_news.start() self.post_maillist_news.start() @@ -71,9 +74,6 @@ class News(Cog): async with self.bot.http_session.get(PEPS_RSS_URL) as resp: data = feedparser.parse(await resp.text()) - news_channel = self.bot.get_channel(constants.PythonNews.channel) - webhook = await self.bot.fetch_webhook(constants.PythonNews.webhook) - news_listing = await self.bot.api_client.get("bot/bot-settings/news") payload = news_listing.copy() pep_news_ids = news_listing["data"]["pep"] @@ -82,11 +82,11 @@ class News(Cog): for pep_id in pep_news_ids: message = discord.utils.get(self.bot.cached_messages, id=pep_id) if message is None: - message = await news_channel.fetch_message(pep_id) + message = await self.channel.fetch_message(pep_id) if message is None: log.warning("Can't fetch PEP new message ID.") continue - pep_news.append((message.embeds[0].title, message.embeds[0].timestamp)) + pep_news.append(message.embeds[0].title) # Reverse entries to send oldest first data["entries"].reverse() @@ -97,21 +97,17 @@ class News(Cog): log.warning(f"Wrong datetime format passed in PEP new: {new['published']}") continue if ( - (any(pep_new[0] == new["title"] for pep_new in pep_news) - and any(pep_new[1] == new_datetime for pep_new in pep_news)) + any(pep_new == new["title"] for pep_new in pep_news) or new_datetime.date() < date.today() ): continue msg_id = await self.send_webhook( - webhook, - new["title"], - new["summary"], - new_datetime, - new["link"], - None, - None, - data["feed"]["title"] + title=new["title"], + description=new["summary"], + timestamp=new_datetime, + url=new["link"], + webhook_profile_name=data["feed"]["title"] ) payload["data"]["pep"].append(msg_id) @@ -122,7 +118,7 @@ class News(Cog): async def post_maillist_news(self) -> None: """Send new maillist threads to #python-news that is listed in configuration.""" await self.bot.wait_until_guild_available() - webhook = await self.bot.fetch_webhook(constants.PythonNews.webhook) + await self.sync_maillists() existing_news = await self.bot.api_client.get("bot/bot-settings/news") payload = existing_news.copy() @@ -154,14 +150,13 @@ class News(Cog): content = email_information["content"] link = THREAD_URL.format(id=thread["href"].split("/")[-2], list=maillist) msg_id = await self.send_webhook( - webhook, - thread_information["subject"], - content[:500] + f"... [continue reading]({link})" if len(content) > 500 else content, - new_date, - link, - f"{email_information['sender_name']} ({email_information['sender']['address']})", - MAILMAN_PROFILE_URL.format(id=email_information["sender"]["mailman_id"]), - self.webhook_names[maillist] + title=thread_information["subject"], + description=content[:500] + f"... [continue reading]({link})" if len(content) > 500 else content, + timestamp=new_date, + url=link, + author=f"{email_information['sender_name']} ({email_information['sender']['address']})", + author_url=MAILMAN_PROFILE_URL.format(id=email_information["sender"]["mailman_id"]), + webhook_profile_name=self.webhook_names[maillist] ) payload["data"][maillist].append(msg_id) @@ -169,12 +164,10 @@ class News(Cog): async def check_new_exist(self, title: str, timestamp: datetime, maillist: str, news: t.Dict[str, t.Any]) -> bool: """Check does this new title + timestamp already exist in #python-news.""" - channel = await self.bot.fetch_channel(constants.PythonNews.channel) - for new in news["data"][maillist]: message = discord.utils.get(self.bot.cached_messages, id=new) if message is None: - message = await channel.fetch_message(new) + message = await self.channel.fetch_message(new) if message is None: return False @@ -183,14 +176,13 @@ class News(Cog): return False async def send_webhook(self, - webhook: discord.Webhook, title: str, description: str, timestamp: datetime, url: str, - author: str, - author_url: str, - webhook_profile_name: str + webhook_profile_name: str, + author: t.Optional[str] = None, + author_url: t.Optional[str] = None, ) -> int: """Send webhook entry and return ID of message.""" embed = discord.Embed( @@ -200,11 +192,12 @@ class News(Cog): url=url, colour=constants.Colours.soft_green ) - embed.set_author( - name=author, - url=author_url - ) - msg = await webhook.send( + if author and author_url: + embed.set_author( + name=author, + url=author_url + ) + msg = await self.webhook.send( embed=embed, username=webhook_profile_name, avatar_url=AVATAR_URL, @@ -223,6 +216,12 @@ class News(Cog): email_information = await resp.json() return thread_information, email_information + async def get_webhook_and_channel(self) -> None: + """Storage #python-news channel Webhook and `TextChannel` to `News.webhook` and `channel`.""" + await self.bot.wait_until_guild_available() + self.webhook = await self.bot.fetch_webhook(constants.PythonNews.webhook) + self.channel = await self.bot.fetch_channel(constants.PythonNews.channel) + def setup(bot: Bot) -> None: """Add `News` cog.""" -- cgit v1.2.3 From cb3b2de26c654fa05816b72291e54762c42fad2c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 21 Apr 2020 15:33:31 +0300 Subject: Simplified title check even more in PEP news --- bot/cogs/news.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/news.py b/bot/cogs/news.py index 21ddb6128..83b4989b3 100644 --- a/bot/cogs/news.py +++ b/bot/cogs/news.py @@ -97,7 +97,7 @@ class News(Cog): log.warning(f"Wrong datetime format passed in PEP new: {new['published']}") continue if ( - any(pep_new == new["title"] for pep_new in pep_news) + new["title"] in pep_news or new_datetime.date() < date.today() ): continue -- cgit v1.2.3 From 547de1af19038470e5c5a8f2120be40e197a97a8 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 27 Apr 2020 08:21:04 +0300 Subject: Improved `News` cog - Added footer to webhook sent message - Made `send_webhook` return `discord.Message` instead ID of message - Added waiting for Webhook on `send_webhook` - Added message publishing in new loops --- bot/cogs/news.py | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/bot/cogs/news.py b/bot/cogs/news.py index 83b4989b3..be1284ca4 100644 --- a/bot/cogs/news.py +++ b/bot/cogs/news.py @@ -102,14 +102,17 @@ class News(Cog): ): continue - msg_id = await self.send_webhook( + msg = await self.send_webhook( title=new["title"], description=new["summary"], timestamp=new_datetime, url=new["link"], - webhook_profile_name=data["feed"]["title"] + webhook_profile_name=data["feed"]["title"], + footer=data["feed"]["title"] ) - payload["data"]["pep"].append(msg_id) + payload["data"]["pep"].append(msg.id) + + await msg.publish() # Apply new sent news to DB to avoid duplicate sending await self.bot.api_client.put("bot/bot-settings/news", json=payload) @@ -149,16 +152,19 @@ class News(Cog): content = email_information["content"] link = THREAD_URL.format(id=thread["href"].split("/")[-2], list=maillist) - msg_id = await self.send_webhook( + msg = await self.send_webhook( title=thread_information["subject"], description=content[:500] + f"... [continue reading]({link})" if len(content) > 500 else content, timestamp=new_date, url=link, author=f"{email_information['sender_name']} ({email_information['sender']['address']})", author_url=MAILMAN_PROFILE_URL.format(id=email_information["sender"]["mailman_id"]), - webhook_profile_name=self.webhook_names[maillist] + webhook_profile_name=self.webhook_names[maillist], + footer=f"Posted to {self.webhook_names[maillist]}" ) - payload["data"][maillist].append(msg_id) + payload["data"][maillist].append(msg.id) + + await msg.publish() await self.bot.api_client.put("bot/bot-settings/news", json=payload) @@ -181,10 +187,11 @@ class News(Cog): timestamp: datetime, url: str, webhook_profile_name: str, + footer: str, author: t.Optional[str] = None, author_url: t.Optional[str] = None, - ) -> int: - """Send webhook entry and return ID of message.""" + ) -> discord.Message: + """Send webhook entry and return sent message.""" embed = discord.Embed( title=title, description=description, @@ -197,13 +204,18 @@ class News(Cog): name=author, url=author_url ) - msg = await self.webhook.send( + embed.set_footer(text=footer, icon_url=AVATAR_URL) + + # Wait until Webhook is available + while not self.webhook: + pass + + return await self.webhook.send( embed=embed, username=webhook_profile_name, avatar_url=AVATAR_URL, wait=True ) - return msg.id async def get_thread_and_first_mail(self, maillist: str, thread_identifier: str) -> t.Tuple[t.Any, t.Any]: """Get mail thread and first mail from mail.python.org based on `maillist` and `thread_identifier`.""" -- cgit v1.2.3 From 07808f816aaf59beb2a3da6f115cd4b6577ea9c6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 27 Apr 2020 09:17:23 +0300 Subject: Fixed `BeautifulSoup` parsing warning Added `features="lxml"` to `BeautifulSoup` class creating to avoid warning. --- bot/cogs/news.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/news.py b/bot/cogs/news.py index be1284ca4..db273d68d 100644 --- a/bot/cogs/news.py +++ b/bot/cogs/news.py @@ -127,7 +127,7 @@ class News(Cog): for maillist in constants.PythonNews.mail_lists: async with self.bot.http_session.get(RECENT_THREADS_TEMPLATE.format(name=maillist)) as resp: - recents = BeautifulSoup(await resp.text()) + recents = BeautifulSoup(await resp.text(), features="lxml") for thread in recents.html.body.div.find_all("a", href=True): # We want only these threads that have identifiers -- cgit v1.2.3 From 6ba5999089ca1a9d79e32dd7ceefbf3d865c35f9 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 27 Apr 2020 20:21:35 +0300 Subject: Add Python News channel and webhook ID to config-default.yml Co-Authored-By: Joseph --- config-default.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config-default.yml b/config-default.yml index 553afaa33..2cc15c370 100644 --- a/config-default.yml +++ b/config-default.yml @@ -122,7 +122,7 @@ guild: channels: announcements: 354619224620138496 user_event_announcements: &USER_EVENT_A 592000283102674944 - python_news: &PYNEWS_CHANNEL 701667765102051398 + python_news: &PYNEWS_CHANNEL 704372456592506880 # Development dev_contrib: &DEV_CONTRIB 635950537262759947 @@ -237,7 +237,7 @@ guild: reddit: 635408384794951680 duck_pond: 637821475327311927 dev_log: 680501655111729222 - python_news: &PYNEWS_WEBHOOK 701731296342179850 + python_news: &PYNEWS_WEBHOOK 704381182279942324 filter: -- cgit v1.2.3 From 12a7dc28589d2e26e2c843ee1364e9c183ec0035 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Mon, 27 Apr 2020 20:31:55 +0100 Subject: Make some fixes to ensure data is persisted and the bot does not hang --- bot/cogs/news.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/bot/cogs/news.py b/bot/cogs/news.py index db273d68d..aa2b2ab8c 100644 --- a/bot/cogs/news.py +++ b/bot/cogs/news.py @@ -5,6 +5,7 @@ from datetime import date, datetime import discord import feedparser from bs4 import BeautifulSoup +from dateutil import tz from discord.ext.commands import Cog from discord.ext.tasks import loop @@ -35,6 +36,8 @@ class News(Cog): self.bot.loop.create_task(self.get_webhook_names()) self.bot.loop.create_task(self.get_webhook_and_channel()) + async def start_tasks(self) -> None: + """Start the tasks for fetching new PEPs and mailing list messages.""" self.post_pep_news.start() self.post_maillist_news.start() @@ -70,6 +73,7 @@ class News(Cog): """Fetch new PEPs and when they don't have announcement in #python-news, create it.""" # Wait until everything is ready and http_session available await self.bot.wait_until_guild_available() + await self.sync_maillists() async with self.bot.http_session.get(PEPS_RSS_URL) as resp: data = feedparser.parse(await resp.text()) @@ -112,7 +116,9 @@ class News(Cog): ) payload["data"]["pep"].append(msg.id) - await msg.publish() + if msg.channel.type is discord.ChannelType.news: + log.trace("Publishing PEP annnouncement because it was in a news channel") + await msg.publish() # Apply new sent news to DB to avoid duplicate sending await self.bot.api_client.put("bot/bot-settings/news", json=payload) @@ -164,7 +170,9 @@ class News(Cog): ) payload["data"][maillist].append(msg.id) - await msg.publish() + if msg.channel.type is discord.ChannelType.news: + log.trace("Publishing PEP annnouncement because it was in a news channel") + await msg.publish() await self.bot.api_client.put("bot/bot-settings/news", json=payload) @@ -175,10 +183,19 @@ class News(Cog): if message is None: message = await self.channel.fetch_message(new) if message is None: + log.trace(f"Could not find message for {new} on mailing list {maillist}") return False - if message.embeds[0].title == title and message.embeds[0].timestamp == timestamp: + embed_time = message.embeds[0].timestamp.replace(tzinfo=tz.gettz("UTC")) + + if ( + message.embeds[0].title == title + and embed_time == timestamp.astimezone(tz.gettz("UTC")) + ): + log.trace(f"Found existing message for '{title}'") return True + + log.trace(f"Found no existing message for '{title}'") return False async def send_webhook(self, @@ -234,6 +251,8 @@ class News(Cog): self.webhook = await self.bot.fetch_webhook(constants.PythonNews.webhook) self.channel = await self.bot.fetch_channel(constants.PythonNews.channel) + await self.start_tasks() + def setup(bot: Bot) -> None: """Add `News` cog.""" -- cgit v1.2.3 From 2cc1d3fc04a989fce1fd6da7d49c1c105678ef68 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Mon, 27 Apr 2020 20:33:28 +0100 Subject: Minor terminology change on a log --- bot/cogs/news.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/news.py b/bot/cogs/news.py index aa2b2ab8c..d7b2bcabb 100644 --- a/bot/cogs/news.py +++ b/bot/cogs/news.py @@ -171,7 +171,7 @@ class News(Cog): payload["data"][maillist].append(msg.id) if msg.channel.type is discord.ChannelType.news: - log.trace("Publishing PEP annnouncement because it was in a news channel") + log.trace("Publishing mailing list message because it was in a news channel") await msg.publish() await self.bot.api_client.put("bot/bot-settings/news", json=payload) -- cgit v1.2.3 From d49516c3d4231569f2f2ec6bde84299ded6fc2f4 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 28 Apr 2020 18:44:26 +0300 Subject: Simplified New publishing check + removed unnecessary Webhook check - Replaced type checking with `TextChannel.is_news()` for simplification to check is possible to publish new - Removed unnecessary `while` loop on `send_webhook` that check is webhook available. No need for this after starting ordering modification. --- bot/cogs/news.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/bot/cogs/news.py b/bot/cogs/news.py index d7b2bcabb..66645bca7 100644 --- a/bot/cogs/news.py +++ b/bot/cogs/news.py @@ -116,7 +116,7 @@ class News(Cog): ) payload["data"]["pep"].append(msg.id) - if msg.channel.type is discord.ChannelType.news: + if msg.channel.is_news(): log.trace("Publishing PEP annnouncement because it was in a news channel") await msg.publish() @@ -170,7 +170,7 @@ class News(Cog): ) payload["data"][maillist].append(msg.id) - if msg.channel.type is discord.ChannelType.news: + if msg.channel.is_news(): log.trace("Publishing mailing list message because it was in a news channel") await msg.publish() @@ -223,10 +223,6 @@ class News(Cog): ) embed.set_footer(text=footer, icon_url=AVATAR_URL) - # Wait until Webhook is available - while not self.webhook: - pass - return await self.webhook.send( embed=embed, username=webhook_profile_name, -- cgit v1.2.3 From d498dd612f5f8252de6c09da045d7d91e2103555 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 1 May 2020 15:46:34 +0300 Subject: Replace message ID storage to new specific ID storage in `News` cog - Removed (now) unnecessary helper function `News.check_new_exist`. - Use thread IDs instead message IDs on maillists checking to avoid Discord API calls. - Use PEP number instead message IDs on PEP news checking to avoid Discord API calls. --- bot/cogs/news.py | 44 ++++++-------------------------------------- 1 file changed, 6 insertions(+), 38 deletions(-) diff --git a/bot/cogs/news.py b/bot/cogs/news.py index 66645bca7..c5b89cf57 100644 --- a/bot/cogs/news.py +++ b/bot/cogs/news.py @@ -5,7 +5,6 @@ from datetime import date, datetime import discord import feedparser from bs4 import BeautifulSoup -from dateutil import tz from discord.ext.commands import Cog from discord.ext.tasks import loop @@ -80,17 +79,7 @@ class News(Cog): news_listing = await self.bot.api_client.get("bot/bot-settings/news") payload = news_listing.copy() - pep_news_ids = news_listing["data"]["pep"] - pep_news = [] - - for pep_id in pep_news_ids: - message = discord.utils.get(self.bot.cached_messages, id=pep_id) - if message is None: - message = await self.channel.fetch_message(pep_id) - if message is None: - log.warning("Can't fetch PEP new message ID.") - continue - pep_news.append(message.embeds[0].title) + pep_numbers = news_listing["data"]["pep"] # Reverse entries to send oldest first data["entries"].reverse() @@ -100,8 +89,9 @@ class News(Cog): except ValueError: log.warning(f"Wrong datetime format passed in PEP new: {new['published']}") continue + pep_nr = new["title"].split(":")[0].split()[1] if ( - new["title"] in pep_news + pep_nr in pep_numbers or new_datetime.date() < date.today() ): continue @@ -114,7 +104,7 @@ class News(Cog): webhook_profile_name=data["feed"]["title"], footer=data["feed"]["title"] ) - payload["data"]["pep"].append(msg.id) + payload["data"]["pep"].append(pep_nr) if msg.channel.is_news(): log.trace("Publishing PEP annnouncement because it was in a news channel") @@ -151,7 +141,7 @@ class News(Cog): continue if ( - await self.check_new_exist(thread_information["subject"], new_date, maillist, existing_news) + thread_information["thread_id"] in existing_news["data"][maillist] or new_date.date() < date.today() ): continue @@ -168,7 +158,7 @@ class News(Cog): webhook_profile_name=self.webhook_names[maillist], footer=f"Posted to {self.webhook_names[maillist]}" ) - payload["data"][maillist].append(msg.id) + payload["data"][maillist].append(thread_information["thread_id"]) if msg.channel.is_news(): log.trace("Publishing mailing list message because it was in a news channel") @@ -176,28 +166,6 @@ class News(Cog): await self.bot.api_client.put("bot/bot-settings/news", json=payload) - async def check_new_exist(self, title: str, timestamp: datetime, maillist: str, news: t.Dict[str, t.Any]) -> bool: - """Check does this new title + timestamp already exist in #python-news.""" - for new in news["data"][maillist]: - message = discord.utils.get(self.bot.cached_messages, id=new) - if message is None: - message = await self.channel.fetch_message(new) - if message is None: - log.trace(f"Could not find message for {new} on mailing list {maillist}") - return False - - embed_time = message.embeds[0].timestamp.replace(tzinfo=tz.gettz("UTC")) - - if ( - message.embeds[0].title == title - and embed_time == timestamp.astimezone(tz.gettz("UTC")) - ): - log.trace(f"Found existing message for '{title}'") - return True - - log.trace(f"Found no existing message for '{title}'") - return False - async def send_webhook(self, title: str, description: str, -- cgit v1.2.3 From 28fb3b83461f9375133ae8cfed6018f7b84c4a7e Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 1 May 2020 15:51:08 +0300 Subject: Added on cog unload news posting tasks canceling on `News` cog --- bot/cogs/news.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bot/cogs/news.py b/bot/cogs/news.py index c5b89cf57..ecc8edaf3 100644 --- a/bot/cogs/news.py +++ b/bot/cogs/news.py @@ -217,6 +217,11 @@ class News(Cog): await self.start_tasks() + def cog_unload(self) -> None: + """Stop news posting tasks on cog unload.""" + self.post_pep_news.cancel() + self.post_maillist_news.cancel() + def setup(bot: Bot) -> None: """Add `News` cog.""" -- cgit v1.2.3 From 5e55a34f3a3edcb041e6ea876055c7e593c707cc Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 1 May 2020 17:26:56 +0300 Subject: Added ignoring maillist when no recent threads (this month) in `News` cog --- bot/cogs/news.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/cogs/news.py b/bot/cogs/news.py index ecc8edaf3..ff2277283 100644 --- a/bot/cogs/news.py +++ b/bot/cogs/news.py @@ -125,6 +125,10 @@ class News(Cog): async with self.bot.http_session.get(RECENT_THREADS_TEMPLATE.format(name=maillist)) as resp: recents = BeautifulSoup(await resp.text(), features="lxml") + # When response have

, this mean that no threads available + if recents.p: + continue + for thread in recents.html.body.div.find_all("a", href=True): # We want only these threads that have identifiers if "latest" in thread["href"]: -- cgit v1.2.3 From 8647fd856fc23cf5f4162498f44bbcd9c576de44 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 1 May 2020 15:54:18 +0100 Subject: Merge the two asynchronous tasks into one to prevent race conditions --- bot/cogs/news.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/bot/cogs/news.py b/bot/cogs/news.py index ff2277283..a81a50f21 100644 --- a/bot/cogs/news.py +++ b/bot/cogs/news.py @@ -37,8 +37,13 @@ class News(Cog): async def start_tasks(self) -> None: """Start the tasks for fetching new PEPs and mailing list messages.""" - self.post_pep_news.start() - self.post_maillist_news.start() + self.fetch_new_media.start() + + @loop(minutes=20) + async def fetch_new_media(self) -> None: + """Fetch new mailing list messages and then new PEPs.""" + await self.post_maillist_news() + await self.post_pep_news() async def sync_maillists(self) -> None: """Sync currently in-use maillists with API.""" @@ -67,7 +72,6 @@ class News(Cog): if mail["name"].split("@")[0] in constants.PythonNews.mail_lists: self.webhook_names[mail["name"].split("@")[0]] = mail["display_name"] - @loop(minutes=20) async def post_pep_news(self) -> None: """Fetch new PEPs and when they don't have announcement in #python-news, create it.""" # Wait until everything is ready and http_session available @@ -113,7 +117,6 @@ class News(Cog): # Apply new sent news to DB to avoid duplicate sending await self.bot.api_client.put("bot/bot-settings/news", json=payload) - @loop(minutes=20) async def post_maillist_news(self) -> None: """Send new maillist threads to #python-news that is listed in configuration.""" await self.bot.wait_until_guild_available() @@ -223,8 +226,7 @@ class News(Cog): def cog_unload(self) -> None: """Stop news posting tasks on cog unload.""" - self.post_pep_news.cancel() - self.post_maillist_news.cancel() + self.fetch_new_media.cancel() def setup(bot: Bot) -> None: -- cgit v1.2.3 From 79b6b1519c0c4b4bc46de12e76257d31338d0678 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 2 May 2020 08:59:56 +0300 Subject: Define encoding in `News` cog `await resp.text()` using In `News` cog PEP news posting, define `utf-8` as encoding on response parsing to avoid the error. Co-authored-by: Joseph Banks --- bot/cogs/news.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/news.py b/bot/cogs/news.py index a81a50f21..c716f662b 100644 --- a/bot/cogs/news.py +++ b/bot/cogs/news.py @@ -79,7 +79,7 @@ class News(Cog): await self.sync_maillists() async with self.bot.http_session.get(PEPS_RSS_URL) as resp: - data = feedparser.parse(await resp.text()) + data = feedparser.parse(await resp.text("utf-8")) news_listing = await self.bot.api_client.get("bot/bot-settings/news") payload = news_listing.copy() -- cgit v1.2.3 From 3a446f4419d8812df1d3892e43b50dd87fc26708 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 3 May 2020 07:07:13 +0300 Subject: Fix `News` cog maillist news posting no threads check comment Co-authored-by: Joseph Banks --- bot/cogs/news.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/news.py b/bot/cogs/news.py index c716f662b..fc79f2fdc 100644 --- a/bot/cogs/news.py +++ b/bot/cogs/news.py @@ -128,7 +128,9 @@ class News(Cog): async with self.bot.http_session.get(RECENT_THREADS_TEMPLATE.format(name=maillist)) as resp: recents = BeautifulSoup(await resp.text(), features="lxml") - # When response have

, this mean that no threads available + # When a

element is present in the response then the mailing list + # has not had any activity during the current month, so therefore it + # can be ignored. if recents.p: continue -- cgit v1.2.3 From 2f924baafe3ae44b71bd60d1bac2c7f1cd98988b Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Mon, 4 May 2020 13:42:05 -0500 Subject: Perma Bans now Overwrite Temp Bans - Changed `has_active_infraction` to `get_active_infractions` in order to add additional logic in `apply_ban`. - Added `send_msg` parameters to `pardon_infraction` and `get_active_infractions` so that multi-step checks and actions don't need to send additional messages unless told to do so. Signed-off-by: Daniel Brown --- bot/cogs/moderation/infractions.py | 30 +++++++++++++++++++++++++++--- bot/cogs/moderation/scheduler.py | 17 ++++++++++++----- bot/cogs/moderation/superstarify.py | 2 +- bot/cogs/moderation/utils.py | 24 +++++++++++++++--------- 4 files changed, 55 insertions(+), 18 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index efa19f59e..29b4db20e 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -199,7 +199,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def apply_mute(self, ctx: Context, user: Member, reason: str, **kwargs) -> None: """Apply a mute infraction with kwargs passed to `post_infraction`.""" - if await utils.has_active_infraction(ctx, user, "mute"): + if await utils.get_active_infractions(ctx, user, "mute"): return infraction = await utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs) @@ -235,8 +235,32 @@ class Infractions(InfractionScheduler, commands.Cog): Will also remove the banned user from the Big Brother watch list if applicable. """ - if await utils.has_active_infraction(ctx, user, "ban"): - return + # In the case of a permanent ban, we don't need get_active_infractions to tell us if one is active + send_msg = "expires_at" in kwargs + active_infraction = await utils.get_active_infractions(ctx, user, "ban", send_msg) + + if active_infraction: + log.trace("Active infractions found.") + if ( + active_infraction.get('expires_at') is not None + and kwargs.get('expires_at') is None + ): + log.trace("Active ban is a temporary and being called by a perma. Removing temporary.") + await self.pardon_infraction(ctx, "ban", user, send_msg) + + elif ( + active_infraction.get('expires_at') is None + and kwargs.get('expires_at') is None + ): + log.trace("Active ban is a perma ban and being called by a perma. Send bounce back message.") + await ctx.send( + f":x: According to my records, this user is already permanently banned. " + f"See infraction **#{active_infraction['id']}**." + ) + return + else: + log.trace("Active ban is a temp ban being called by a temp or a perma being called by a temp. Ignore.") + return infraction = await utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs) if infraction is None: diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 917697be9..413717fb6 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -190,7 +190,13 @@ class InfractionScheduler(Scheduler): log.info(f"Applied {infr_type} infraction #{id_} to {user}.") - async def pardon_infraction(self, ctx: Context, infr_type: str, user: UserSnowflake) -> None: + async def pardon_infraction( + self, + ctx: Context, + infr_type: str, + user: UserSnowflake, + send_msg: bool = True + ) -> None: """Prematurely end an infraction for a user and log the action in the mod log.""" log.trace(f"Pardoning {infr_type} infraction for {user}.") @@ -277,10 +283,11 @@ class InfractionScheduler(Scheduler): # Send a confirmation message to the invoking context. log.trace(f"Sending infraction #{id_} pardon confirmation message.") - await ctx.send( - f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " - f"{log_text.get('Failure', '')}" - ) + if send_msg: + await ctx.send( + f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " + f"{log_text.get('Failure', '')}" + ) # Send a log message to the mod log. await self.mod_log.send_log_message( diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index ca3dc4202..272f7c4f0 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -130,7 +130,7 @@ class Superstarify(InfractionScheduler, Cog): An optional reason can be provided. If no reason is given, the original name will be shown in a generated reason. """ - if await utils.has_active_infraction(ctx, member, "superstar"): + if await utils.get_active_infractions(ctx, member, "superstar"): return # Post the infraction to the API diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 3598f3b1f..406f9d08a 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -97,8 +97,13 @@ async def post_infraction( return -async def has_active_infraction(ctx: Context, user: UserSnowflake, infr_type: str) -> bool: - """Checks if a user already has an active infraction of the given type.""" +async def get_active_infractions( + ctx: Context, + user: UserSnowflake, + infr_type: str, + send_msg: bool = True +) -> t.Optional[dict]: + """Retrieves active infractions of the given type for the user.""" log.trace(f"Checking if {user} has active infractions of type {infr_type}.") active_infractions = await ctx.bot.api_client.get( @@ -110,15 +115,16 @@ async def has_active_infraction(ctx: Context, user: UserSnowflake, infr_type: st } ) if active_infractions: - log.trace(f"{user} has active infractions of type {infr_type}.") - await ctx.send( - f":x: According to my records, this user already has a {infr_type} infraction. " - f"See infraction **#{active_infractions[0]['id']}**." - ) - return True + # Checks to see if the moderator should be told there is an active infraction + if send_msg: + log.trace(f"{user} has active infractions of type {infr_type}.") + await ctx.send( + f":x: According to my records, this user already has a {infr_type} infraction. " + f"See infraction **#{active_infractions[0]['id']}**." + ) + return active_infractions[0] else: log.trace(f"{user} does not have active infractions of type {infr_type}.") - return False async def notify_infraction( -- cgit v1.2.3 From 2368ab4d6f107868c40036438a7320a10d3c0184 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 6 May 2020 19:06:34 +0300 Subject: Fix config Webhook IDs formatting Co-authored-by: Sebastiaan Zeeff --- config-default.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/config-default.yml b/config-default.yml index 2cc15c370..90d5b1d29 100644 --- a/config-default.yml +++ b/config-default.yml @@ -232,12 +232,12 @@ guild: - *HELPERS_ROLE webhooks: - talent_pool: 569145364800602132 - big_brother: 569133704568373283 - reddit: 635408384794951680 - duck_pond: 637821475327311927 - dev_log: 680501655111729222 - python_news: &PYNEWS_WEBHOOK 704381182279942324 + talent_pool: 569145364800602132 + big_brother: 569133704568373283 + reddit: 635408384794951680 + duck_pond: 637821475327311927 + dev_log: 680501655111729222 + python_news: &PYNEWS_WEBHOOK 704381182279942324 filter: -- cgit v1.2.3 From bcb360b262531e14eb00bfdc36d9da9b260fdcff Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 6 May 2020 19:18:23 +0300 Subject: Renamed `news.py` to `python_news.py` and `News` to `PythonNews` to avoid confusion --- bot/cogs/news.py | 232 ------------------------------------------------ bot/cogs/python_news.py | 232 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+), 232 deletions(-) delete mode 100644 bot/cogs/news.py create mode 100644 bot/cogs/python_news.py diff --git a/bot/cogs/news.py b/bot/cogs/news.py deleted file mode 100644 index ff2277283..000000000 --- a/bot/cogs/news.py +++ /dev/null @@ -1,232 +0,0 @@ -import logging -import typing as t -from datetime import date, datetime - -import discord -import feedparser -from bs4 import BeautifulSoup -from discord.ext.commands import Cog -from discord.ext.tasks import loop - -from bot import constants -from bot.bot import Bot - -PEPS_RSS_URL = "https://www.python.org/dev/peps/peps.rss/" - -RECENT_THREADS_TEMPLATE = "https://mail.python.org/archives/list/{name}@python.org/recent-threads" -THREAD_TEMPLATE_URL = "https://mail.python.org/archives/api/list/{name}@python.org/thread/{id}/" -MAILMAN_PROFILE_URL = "https://mail.python.org/archives/users/{id}/" -THREAD_URL = "https://mail.python.org/archives/list/{list}@python.org/thread/{id}/" - -AVATAR_URL = "https://www.python.org/static/opengraph-icon-200x200.png" - -log = logging.getLogger(__name__) - - -class News(Cog): - """Post new PEPs and Python News to `#python-news`.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.webhook_names = {} - self.webhook: t.Optional[discord.Webhook] = None - self.channel: t.Optional[discord.TextChannel] = None - - self.bot.loop.create_task(self.get_webhook_names()) - self.bot.loop.create_task(self.get_webhook_and_channel()) - - async def start_tasks(self) -> None: - """Start the tasks for fetching new PEPs and mailing list messages.""" - self.post_pep_news.start() - self.post_maillist_news.start() - - async def sync_maillists(self) -> None: - """Sync currently in-use maillists with API.""" - # Wait until guild is available to avoid running before everything is ready - await self.bot.wait_until_guild_available() - - response = await self.bot.api_client.get("bot/bot-settings/news") - for mail in constants.PythonNews.mail_lists: - if mail not in response["data"]: - response["data"][mail] = [] - - # Because we are handling PEPs differently, we don't include it to mail lists - if "pep" not in response["data"]: - response["data"]["pep"] = [] - - await self.bot.api_client.put("bot/bot-settings/news", json=response) - - async def get_webhook_names(self) -> None: - """Get webhook author names from maillist API.""" - await self.bot.wait_until_guild_available() - - async with self.bot.http_session.get("https://mail.python.org/archives/api/lists") as resp: - lists = await resp.json() - - for mail in lists: - if mail["name"].split("@")[0] in constants.PythonNews.mail_lists: - self.webhook_names[mail["name"].split("@")[0]] = mail["display_name"] - - @loop(minutes=20) - async def post_pep_news(self) -> None: - """Fetch new PEPs and when they don't have announcement in #python-news, create it.""" - # Wait until everything is ready and http_session available - await self.bot.wait_until_guild_available() - await self.sync_maillists() - - async with self.bot.http_session.get(PEPS_RSS_URL) as resp: - data = feedparser.parse(await resp.text()) - - news_listing = await self.bot.api_client.get("bot/bot-settings/news") - payload = news_listing.copy() - pep_numbers = news_listing["data"]["pep"] - - # Reverse entries to send oldest first - data["entries"].reverse() - for new in data["entries"]: - try: - new_datetime = datetime.strptime(new["published"], "%a, %d %b %Y %X %Z") - except ValueError: - log.warning(f"Wrong datetime format passed in PEP new: {new['published']}") - continue - pep_nr = new["title"].split(":")[0].split()[1] - if ( - pep_nr in pep_numbers - or new_datetime.date() < date.today() - ): - continue - - msg = await self.send_webhook( - title=new["title"], - description=new["summary"], - timestamp=new_datetime, - url=new["link"], - webhook_profile_name=data["feed"]["title"], - footer=data["feed"]["title"] - ) - payload["data"]["pep"].append(pep_nr) - - if msg.channel.is_news(): - log.trace("Publishing PEP annnouncement because it was in a news channel") - await msg.publish() - - # Apply new sent news to DB to avoid duplicate sending - await self.bot.api_client.put("bot/bot-settings/news", json=payload) - - @loop(minutes=20) - async def post_maillist_news(self) -> None: - """Send new maillist threads to #python-news that is listed in configuration.""" - await self.bot.wait_until_guild_available() - await self.sync_maillists() - existing_news = await self.bot.api_client.get("bot/bot-settings/news") - payload = existing_news.copy() - - for maillist in constants.PythonNews.mail_lists: - async with self.bot.http_session.get(RECENT_THREADS_TEMPLATE.format(name=maillist)) as resp: - recents = BeautifulSoup(await resp.text(), features="lxml") - - # When response have

, this mean that no threads available - if recents.p: - continue - - for thread in recents.html.body.div.find_all("a", href=True): - # We want only these threads that have identifiers - if "latest" in thread["href"]: - continue - - thread_information, email_information = await self.get_thread_and_first_mail( - maillist, thread["href"].split("/")[-2] - ) - - try: - new_date = datetime.strptime(email_information["date"], "%Y-%m-%dT%X%z") - except ValueError: - log.warning(f"Invalid datetime from Thread email: {email_information['date']}") - continue - - if ( - thread_information["thread_id"] in existing_news["data"][maillist] - or new_date.date() < date.today() - ): - continue - - content = email_information["content"] - link = THREAD_URL.format(id=thread["href"].split("/")[-2], list=maillist) - msg = await self.send_webhook( - title=thread_information["subject"], - description=content[:500] + f"... [continue reading]({link})" if len(content) > 500 else content, - timestamp=new_date, - url=link, - author=f"{email_information['sender_name']} ({email_information['sender']['address']})", - author_url=MAILMAN_PROFILE_URL.format(id=email_information["sender"]["mailman_id"]), - webhook_profile_name=self.webhook_names[maillist], - footer=f"Posted to {self.webhook_names[maillist]}" - ) - payload["data"][maillist].append(thread_information["thread_id"]) - - if msg.channel.is_news(): - log.trace("Publishing mailing list message because it was in a news channel") - await msg.publish() - - await self.bot.api_client.put("bot/bot-settings/news", json=payload) - - async def send_webhook(self, - title: str, - description: str, - timestamp: datetime, - url: str, - webhook_profile_name: str, - footer: str, - author: t.Optional[str] = None, - author_url: t.Optional[str] = None, - ) -> discord.Message: - """Send webhook entry and return sent message.""" - embed = discord.Embed( - title=title, - description=description, - timestamp=timestamp, - url=url, - colour=constants.Colours.soft_green - ) - if author and author_url: - embed.set_author( - name=author, - url=author_url - ) - embed.set_footer(text=footer, icon_url=AVATAR_URL) - - return await self.webhook.send( - embed=embed, - username=webhook_profile_name, - avatar_url=AVATAR_URL, - wait=True - ) - - async def get_thread_and_first_mail(self, maillist: str, thread_identifier: str) -> t.Tuple[t.Any, t.Any]: - """Get mail thread and first mail from mail.python.org based on `maillist` and `thread_identifier`.""" - async with self.bot.http_session.get( - THREAD_TEMPLATE_URL.format(name=maillist, id=thread_identifier) - ) as resp: - thread_information = await resp.json() - - async with self.bot.http_session.get(thread_information["starting_email"]) as resp: - email_information = await resp.json() - return thread_information, email_information - - async def get_webhook_and_channel(self) -> None: - """Storage #python-news channel Webhook and `TextChannel` to `News.webhook` and `channel`.""" - await self.bot.wait_until_guild_available() - self.webhook = await self.bot.fetch_webhook(constants.PythonNews.webhook) - self.channel = await self.bot.fetch_channel(constants.PythonNews.channel) - - await self.start_tasks() - - def cog_unload(self) -> None: - """Stop news posting tasks on cog unload.""" - self.post_pep_news.cancel() - self.post_maillist_news.cancel() - - -def setup(bot: Bot) -> None: - """Add `News` cog.""" - bot.add_cog(News(bot)) diff --git a/bot/cogs/python_news.py b/bot/cogs/python_news.py new file mode 100644 index 000000000..092ee3cff --- /dev/null +++ b/bot/cogs/python_news.py @@ -0,0 +1,232 @@ +import logging +import typing as t +from datetime import date, datetime + +import discord +import feedparser +from bs4 import BeautifulSoup +from discord.ext.commands import Cog +from discord.ext.tasks import loop + +from bot import constants +from bot.bot import Bot + +PEPS_RSS_URL = "https://www.python.org/dev/peps/peps.rss/" + +RECENT_THREADS_TEMPLATE = "https://mail.python.org/archives/list/{name}@python.org/recent-threads" +THREAD_TEMPLATE_URL = "https://mail.python.org/archives/api/list/{name}@python.org/thread/{id}/" +MAILMAN_PROFILE_URL = "https://mail.python.org/archives/users/{id}/" +THREAD_URL = "https://mail.python.org/archives/list/{list}@python.org/thread/{id}/" + +AVATAR_URL = "https://www.python.org/static/opengraph-icon-200x200.png" + +log = logging.getLogger(__name__) + + +class PythonNews(Cog): + """Post new PEPs and Python News to `#python-news`.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.webhook_names = {} + self.webhook: t.Optional[discord.Webhook] = None + self.channel: t.Optional[discord.TextChannel] = None + + self.bot.loop.create_task(self.get_webhook_names()) + self.bot.loop.create_task(self.get_webhook_and_channel()) + + async def start_tasks(self) -> None: + """Start the tasks for fetching new PEPs and mailing list messages.""" + self.post_pep_news.start() + self.post_maillist_news.start() + + async def sync_maillists(self) -> None: + """Sync currently in-use maillists with API.""" + # Wait until guild is available to avoid running before everything is ready + await self.bot.wait_until_guild_available() + + response = await self.bot.api_client.get("bot/bot-settings/news") + for mail in constants.PythonNews.mail_lists: + if mail not in response["data"]: + response["data"][mail] = [] + + # Because we are handling PEPs differently, we don't include it to mail lists + if "pep" not in response["data"]: + response["data"]["pep"] = [] + + await self.bot.api_client.put("bot/bot-settings/news", json=response) + + async def get_webhook_names(self) -> None: + """Get webhook author names from maillist API.""" + await self.bot.wait_until_guild_available() + + async with self.bot.http_session.get("https://mail.python.org/archives/api/lists") as resp: + lists = await resp.json() + + for mail in lists: + if mail["name"].split("@")[0] in constants.PythonNews.mail_lists: + self.webhook_names[mail["name"].split("@")[0]] = mail["display_name"] + + @loop(minutes=20) + async def post_pep_news(self) -> None: + """Fetch new PEPs and when they don't have announcement in #python-news, create it.""" + # Wait until everything is ready and http_session available + await self.bot.wait_until_guild_available() + await self.sync_maillists() + + async with self.bot.http_session.get(PEPS_RSS_URL) as resp: + data = feedparser.parse(await resp.text()) + + news_listing = await self.bot.api_client.get("bot/bot-settings/news") + payload = news_listing.copy() + pep_numbers = news_listing["data"]["pep"] + + # Reverse entries to send oldest first + data["entries"].reverse() + for new in data["entries"]: + try: + new_datetime = datetime.strptime(new["published"], "%a, %d %b %Y %X %Z") + except ValueError: + log.warning(f"Wrong datetime format passed in PEP new: {new['published']}") + continue + pep_nr = new["title"].split(":")[0].split()[1] + if ( + pep_nr in pep_numbers + or new_datetime.date() < date.today() + ): + continue + + msg = await self.send_webhook( + title=new["title"], + description=new["summary"], + timestamp=new_datetime, + url=new["link"], + webhook_profile_name=data["feed"]["title"], + footer=data["feed"]["title"] + ) + payload["data"]["pep"].append(pep_nr) + + if msg.channel.is_news(): + log.trace("Publishing PEP annnouncement because it was in a news channel") + await msg.publish() + + # Apply new sent news to DB to avoid duplicate sending + await self.bot.api_client.put("bot/bot-settings/news", json=payload) + + @loop(minutes=20) + async def post_maillist_news(self) -> None: + """Send new maillist threads to #python-news that is listed in configuration.""" + await self.bot.wait_until_guild_available() + await self.sync_maillists() + existing_news = await self.bot.api_client.get("bot/bot-settings/news") + payload = existing_news.copy() + + for maillist in constants.PythonNews.mail_lists: + async with self.bot.http_session.get(RECENT_THREADS_TEMPLATE.format(name=maillist)) as resp: + recents = BeautifulSoup(await resp.text(), features="lxml") + + # When response have

, this mean that no threads available + if recents.p: + continue + + for thread in recents.html.body.div.find_all("a", href=True): + # We want only these threads that have identifiers + if "latest" in thread["href"]: + continue + + thread_information, email_information = await self.get_thread_and_first_mail( + maillist, thread["href"].split("/")[-2] + ) + + try: + new_date = datetime.strptime(email_information["date"], "%Y-%m-%dT%X%z") + except ValueError: + log.warning(f"Invalid datetime from Thread email: {email_information['date']}") + continue + + if ( + thread_information["thread_id"] in existing_news["data"][maillist] + or new_date.date() < date.today() + ): + continue + + content = email_information["content"] + link = THREAD_URL.format(id=thread["href"].split("/")[-2], list=maillist) + msg = await self.send_webhook( + title=thread_information["subject"], + description=content[:500] + f"... [continue reading]({link})" if len(content) > 500 else content, + timestamp=new_date, + url=link, + author=f"{email_information['sender_name']} ({email_information['sender']['address']})", + author_url=MAILMAN_PROFILE_URL.format(id=email_information["sender"]["mailman_id"]), + webhook_profile_name=self.webhook_names[maillist], + footer=f"Posted to {self.webhook_names[maillist]}" + ) + payload["data"][maillist].append(thread_information["thread_id"]) + + if msg.channel.is_news(): + log.trace("Publishing mailing list message because it was in a news channel") + await msg.publish() + + await self.bot.api_client.put("bot/bot-settings/news", json=payload) + + async def send_webhook(self, + title: str, + description: str, + timestamp: datetime, + url: str, + webhook_profile_name: str, + footer: str, + author: t.Optional[str] = None, + author_url: t.Optional[str] = None, + ) -> discord.Message: + """Send webhook entry and return sent message.""" + embed = discord.Embed( + title=title, + description=description, + timestamp=timestamp, + url=url, + colour=constants.Colours.soft_green + ) + if author and author_url: + embed.set_author( + name=author, + url=author_url + ) + embed.set_footer(text=footer, icon_url=AVATAR_URL) + + return await self.webhook.send( + embed=embed, + username=webhook_profile_name, + avatar_url=AVATAR_URL, + wait=True + ) + + async def get_thread_and_first_mail(self, maillist: str, thread_identifier: str) -> t.Tuple[t.Any, t.Any]: + """Get mail thread and first mail from mail.python.org based on `maillist` and `thread_identifier`.""" + async with self.bot.http_session.get( + THREAD_TEMPLATE_URL.format(name=maillist, id=thread_identifier) + ) as resp: + thread_information = await resp.json() + + async with self.bot.http_session.get(thread_information["starting_email"]) as resp: + email_information = await resp.json() + return thread_information, email_information + + async def get_webhook_and_channel(self) -> None: + """Storage #python-news channel Webhook and `TextChannel` to `News.webhook` and `channel`.""" + await self.bot.wait_until_guild_available() + self.webhook = await self.bot.fetch_webhook(constants.PythonNews.webhook) + self.channel = await self.bot.fetch_channel(constants.PythonNews.channel) + + await self.start_tasks() + + def cog_unload(self) -> None: + """Stop news posting tasks on cog unload.""" + self.post_pep_news.cancel() + self.post_maillist_news.cancel() + + +def setup(bot: Bot) -> None: + """Add `News` cog.""" + bot.add_cog(PythonNews(bot)) -- cgit v1.2.3 From 442d7199eef085910370506f91fda5bf0ef737a5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 6 May 2020 19:20:51 +0300 Subject: Remove `PythonNews.channel` because this is unnecessary --- bot/cogs/python_news.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bot/cogs/python_news.py b/bot/cogs/python_news.py index 092ee3cff..e3c8b1e1e 100644 --- a/bot/cogs/python_news.py +++ b/bot/cogs/python_news.py @@ -30,7 +30,6 @@ class PythonNews(Cog): self.bot = bot self.webhook_names = {} self.webhook: t.Optional[discord.Webhook] = None - self.channel: t.Optional[discord.TextChannel] = None self.bot.loop.create_task(self.get_webhook_names()) self.bot.loop.create_task(self.get_webhook_and_channel()) @@ -217,7 +216,6 @@ class PythonNews(Cog): """Storage #python-news channel Webhook and `TextChannel` to `News.webhook` and `channel`.""" await self.bot.wait_until_guild_available() self.webhook = await self.bot.fetch_webhook(constants.PythonNews.webhook) - self.channel = await self.bot.fetch_channel(constants.PythonNews.channel) await self.start_tasks() -- cgit v1.2.3 From 5d0cecf514701e9e300174e9d3050bd772f3f96f Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 7 May 2020 21:47:20 +0300 Subject: Update Python News extension name in __main__.py Co-authored-by: Joseph Banks --- bot/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/__main__.py b/bot/__main__.py index 42c1a4f3a..aa1d1aee8 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -51,7 +51,7 @@ bot.load_extension("bot.cogs.eval") bot.load_extension("bot.cogs.information") bot.load_extension("bot.cogs.jams") bot.load_extension("bot.cogs.moderation") -bot.load_extension("bot.cogs.news") +bot.load_extension("bot.cogs.python_news") bot.load_extension("bot.cogs.off_topic_names") bot.load_extension("bot.cogs.reddit") bot.load_extension("bot.cogs.reminders") -- cgit v1.2.3 From 8a64763bb138119f2dea7db0a997d380f48865fc Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Thu, 7 May 2020 15:23:18 -0500 Subject: Addressing Review Changes - Changed docstring explanation and function name of `get_active_infractions` to `get_active_infraction()` to better convey that only one infraction is returned. Also changed all relevant uses to reflect that change. - Added explanation of parameter `send_msg` to the doc strings of `pardon_infraction()` and `get_active_infraction()` - Adjusted placement of `log.trace()` in `pardon_infraction()` - Adjusted logic in `apply_ban()` to remove redundant check. - Adjusted logic in `apply_ban()` to be consistent with other checks. Signed-off-by: Daniel Brown --- bot/cogs/moderation/infractions.py | 35 +++++++++++++++-------------------- bot/cogs/moderation/scheduler.py | 9 +++++++-- bot/cogs/moderation/superstarify.py | 2 +- bot/cogs/moderation/utils.py | 10 ++++++++-- 4 files changed, 31 insertions(+), 25 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 29b4db20e..89f72ade7 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -199,7 +199,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def apply_mute(self, ctx: Context, user: Member, reason: str, **kwargs) -> None: """Apply a mute infraction with kwargs passed to `post_infraction`.""" - if await utils.get_active_infractions(ctx, user, "mute"): + if await utils.get_active_infraction(ctx, user, "mute"): return infraction = await utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs) @@ -236,28 +236,23 @@ class Infractions(InfractionScheduler, commands.Cog): Will also remove the banned user from the Big Brother watch list if applicable. """ # In the case of a permanent ban, we don't need get_active_infractions to tell us if one is active - send_msg = "expires_at" in kwargs - active_infraction = await utils.get_active_infractions(ctx, user, "ban", send_msg) + send_msg = kwargs.get("expires_at") is None + active_infraction = await utils.get_active_infraction(ctx, user, "ban", send_msg) if active_infraction: log.trace("Active infractions found.") - if ( - active_infraction.get('expires_at') is not None - and kwargs.get('expires_at') is None - ): - log.trace("Active ban is a temporary and being called by a perma. Removing temporary.") - await self.pardon_infraction(ctx, "ban", user, send_msg) - - elif ( - active_infraction.get('expires_at') is None - and kwargs.get('expires_at') is None - ): - log.trace("Active ban is a perma ban and being called by a perma. Send bounce back message.") - await ctx.send( - f":x: According to my records, this user is already permanently banned. " - f"See infraction **#{active_infraction['id']}**." - ) - return + if kwargs.get('expires_at') is None: + if active_infraction.get('expires_at') is not None: + log.trace("Active ban is a temporary and being called by a perma. Removing temporary.") + await self.pardon_infraction(ctx, "ban", user, send_msg) + + elif active_infraction.get('expires_at') is None: + log.trace("Active ban is a perma ban and being called by a perma. Send bounce back message.") + await ctx.send( + f":x: According to my records, this user is already permanently banned. " + f"See infraction **#{active_infraction['id']}**." + ) + return else: log.trace("Active ban is a temp ban being called by a temp or a perma being called by a temp. Ignore.") return diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 413717fb6..dc42bee2e 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -197,7 +197,12 @@ class InfractionScheduler(Scheduler): user: UserSnowflake, send_msg: bool = True ) -> None: - """Prematurely end an infraction for a user and log the action in the mod log.""" + """ + Prematurely end an infraction for a user and log the action in the mod log. + + If `send_msg` is True, then a pardoning confirmation message will be sent to + the context channel. Otherwise, no such message will be sent. + """ log.trace(f"Pardoning {infr_type} infraction for {user}.") # Check the current active infraction @@ -282,8 +287,8 @@ class InfractionScheduler(Scheduler): log.info(f"Pardoned {infr_type} infraction #{id_} for {user}.") # Send a confirmation message to the invoking context. - log.trace(f"Sending infraction #{id_} pardon confirmation message.") if send_msg: + log.trace(f"Sending infraction #{id_} pardon confirmation message.") await ctx.send( f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " f"{log_text.get('Failure', '')}" diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 272f7c4f0..29855c325 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -130,7 +130,7 @@ class Superstarify(InfractionScheduler, Cog): An optional reason can be provided. If no reason is given, the original name will be shown in a generated reason. """ - if await utils.get_active_infractions(ctx, member, "superstar"): + if await utils.get_active_infraction(ctx, member, "superstar"): return # Post the infraction to the API diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 406f9d08a..e4e0f1ec2 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -97,13 +97,19 @@ async def post_infraction( return -async def get_active_infractions( +async def get_active_infraction( ctx: Context, user: UserSnowflake, infr_type: str, send_msg: bool = True ) -> t.Optional[dict]: - """Retrieves active infractions of the given type for the user.""" + """ + Retrieves an active infraction of the given type for the user. + + If `send_msg` is True and the user has an active infraction matching the `infr_type` parameter, + then a message for the moderator will be sent to the context channel letting them know. + Otherwise, no message will be sent. + """ log.trace(f"Checking if {user} has active infractions of type {infr_type}.") active_infractions = await ctx.bot.api_client.get( -- cgit v1.2.3 From 79791326bbea2fee3ab06e1055dfe1897e050c51 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Thu, 7 May 2020 15:58:09 -0500 Subject: apply_ban() logic refined - Refined the logic for `apply_ban()` even further to be cleaner. (Thanks, @MarkKoz!) Signed-off-by: Daniel Brown --- bot/cogs/moderation/infractions.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 89f72ade7..19a3176d9 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -236,27 +236,27 @@ class Infractions(InfractionScheduler, commands.Cog): Will also remove the banned user from the Big Brother watch list if applicable. """ # In the case of a permanent ban, we don't need get_active_infractions to tell us if one is active - send_msg = kwargs.get("expires_at") is None - active_infraction = await utils.get_active_infraction(ctx, user, "ban", send_msg) + is_temporary = kwargs.get("expires_at") is not None + active_infraction = await utils.get_active_infraction(ctx, user, "ban", is_temporary) if active_infraction: log.trace("Active infractions found.") - if kwargs.get('expires_at') is None: - if active_infraction.get('expires_at') is not None: - log.trace("Active ban is a temporary and being called by a perma. Removing temporary.") - await self.pardon_infraction(ctx, "ban", user, send_msg) - - elif active_infraction.get('expires_at') is None: - log.trace("Active ban is a perma ban and being called by a perma. Send bounce back message.") - await ctx.send( - f":x: According to my records, this user is already permanently banned. " - f"See infraction **#{active_infraction['id']}**." - ) - return - else: + if is_temporary: log.trace("Active ban is a temp ban being called by a temp or a perma being called by a temp. Ignore.") return + if active_infraction.get('expires_at') is not None: + log.trace("Active ban is a temporary and being called by a perma. Removing temporary.") + await self.pardon_infraction(ctx, "ban", user, is_temporary) + + else: + log.trace("Active ban is a perma ban and being called by a perma. Send bounce back message.") + await ctx.send( + f":x: According to my records, this user is already permanently banned. " + f"See infraction **#{active_infraction['id']}**." + ) + return + infraction = await utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs) if infraction is None: return -- cgit v1.2.3 From b06c60bc3456c36583d3d58cbf62e9ecd14e5f94 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 9 May 2020 23:19:39 -0700 Subject: Filtering: don't delete messages in DMs Bots are incapable of deleting direct messages authored by others. --- bot/cogs/filtering.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 6a703f5a1..1e21a4ce3 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -4,7 +4,7 @@ from typing import Optional, Union import discord.errors from dateutil.relativedelta import relativedelta -from discord import Colour, DMChannel, Member, Message, TextChannel +from discord import Colour, Member, Message, TextChannel from discord.ext.commands import Cog from discord.utils import escape_markdown @@ -161,8 +161,10 @@ class Filtering(Cog): match = await _filter["function"](msg) if match: - # If this is a filter (not a watchlist), we should delete the message. - if _filter["type"] == "filter": + is_private = msg.channel.type is discord.ChannelType.private + + # If this is a filter (not a watchlist) and not in a DM, delete the message. + if _filter["type"] == "filter" and not is_private: try: # Embeds (can?) trigger both the `on_message` and `on_message_edit` # event handlers, triggering filtering twice for the same message. @@ -181,7 +183,7 @@ class Filtering(Cog): if _filter["user_notification"]: await self.notify_member(msg.author, _filter["notification_msg"], msg.channel) - if isinstance(msg.channel, DMChannel): + if is_private: channel_str = "via DM" else: channel_str = f"in {msg.channel.mention}" -- cgit v1.2.3 From 4a4ca1a168c5130d3627d3c4dbc8bfe39119cc22 Mon Sep 17 00:00:00 2001 From: Suhail Date: Sun, 10 May 2020 23:04:04 +0100 Subject: Add remindme alias for the remind command --- bot/cogs/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 24c279357..8b6457cbb 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -158,7 +158,7 @@ class Reminders(Scheduler, Cog): ) await self._delete_reminder(reminder["id"]) - @group(name="remind", aliases=("reminder", "reminders"), invoke_without_command=True) + @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True) async def remind_group(self, ctx: Context, expiration: Duration, *, content: str) -> None: """Commands for managing your reminders.""" await ctx.invoke(self.new_reminder, expiration=expiration, content=content) -- cgit v1.2.3 From d1af9cda00d18d0fee679964ba177f6a3f7ec196 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Mon, 11 May 2020 12:39:49 -0500 Subject: Restructure `apply_ban()` logic Another refactor/cleaning to make the logic clearer and easier to understand. Also cleaned up the trace logs to be shorter and more concise. Thanks, @scragly! Co-authored-by: scragly <29337040+scragly@users.noreply.github.com> --- bot/cogs/moderation/infractions.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 19a3176d9..e62a36c43 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -240,23 +240,18 @@ class Infractions(InfractionScheduler, commands.Cog): active_infraction = await utils.get_active_infraction(ctx, user, "ban", is_temporary) if active_infraction: - log.trace("Active infractions found.") if is_temporary: - log.trace("Active ban is a temp ban being called by a temp or a perma being called by a temp. Ignore.") + log.trace("Tempban ignored as it cannot overwrite an active ban.") return - if active_infraction.get('expires_at') is not None: - log.trace("Active ban is a temporary and being called by a perma. Removing temporary.") - await self.pardon_infraction(ctx, "ban", user, is_temporary) - - else: - log.trace("Active ban is a perma ban and being called by a perma. Send bounce back message.") - await ctx.send( - f":x: According to my records, this user is already permanently banned. " - f"See infraction **#{active_infraction['id']}**." - ) + if active_infraction.get('expires_at') is None: + log.trace("Permaban already exists, notify.") + await ctx.send(f":x: User is already permanently banned (#{active_infraction['id']}).") return + log.trace("Old tempban is being replaced by new permaban.") + await self.pardon_infraction(ctx, "ban", user, is_temporary) + infraction = await utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs) if infraction is None: return -- cgit v1.2.3 From 0c552b2dc3e88b5e13278cb705c371db48c72646 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Tue, 12 May 2020 21:29:35 -0400 Subject: Expand guild whitelist --- config-default.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index ff6790423..6d97b7f33 100644 --- a/config-default.yml +++ b/config-default.yml @@ -263,7 +263,8 @@ filter: guild_invite_whitelist: - 280033776820813825 # Functional Programming - 267624335836053506 # Python Discord - - 440186186024222721 # Python Discord: ModLog Emojis + - 440186186024222721 # Python Discord: Emojis 1 + - 578587418123304970 # Python Discord: Emojis 2 - 273944235143593984 # STEM - 348658686962696195 # RLBot - 531221516914917387 # Pallets @@ -280,6 +281,11 @@ filter: - 336642139381301249 # discord.py - 405403391410438165 # Sentdex - 172018499005317120 # The Coding Den + - 666560367173828639 # PyWeek + - 702724176489873509 # Microsoft Python + - 81384788765712384 # Discord API + - 613425648685547541 # Discord Developers + - 185590609631903755 # Blender Hub domain_blacklist: - pornhub.com -- cgit v1.2.3 From d7e6bed7b5b0f61312165e5b0b2b9291cd8df0c9 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 13 May 2020 14:17:51 +0300 Subject: Add message publishing to `Reddit` cog --- bot/cogs/reddit.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 5a7fa100f..371b65434 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -218,7 +218,10 @@ class Reddit(Cog): for subreddit in RedditConfig.subreddits: top_posts = await self.get_top_posts(subreddit=subreddit, time="day") - await self.webhook.send(username=f"{subreddit} Top Daily Posts", embed=top_posts) + message = await self.webhook.send(username=f"{subreddit} Top Daily Posts", embed=top_posts) + + if message.channel.is_news(): + await message.publish() async def top_weekly_posts(self) -> None: """Post a summary of the top posts.""" @@ -242,6 +245,9 @@ class Reddit(Cog): await message.pin() + if message.channel.is_news(): + await message.publish() + @group(name="reddit", invoke_without_command=True) async def reddit_group(self, ctx: Context) -> None: """View the top posts from various subreddits.""" -- cgit v1.2.3 From 8d595467aba4bcde76caa6f6b9eccf8a9b73b0c5 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 14 May 2020 09:56:55 +0200 Subject: Remove everyone-ping from mentions alert The mentions alert that is sent out by the Verification cog currently pings `@everyone` despite being quite unactionable by most people receiving the ping. As it happens frequently, especially with the recent uptick in joins, I'm removing that ping to not bother our moderators as much. --- bot/cogs/verification.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 388b7a338..b1ab7be4d 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -92,7 +92,6 @@ class Verification(Cog): text=embed_text, thumbnail=message.author.avatar_url_as(static_format="png"), channel_id=constants.Channels.mod_alerts, - ping_everyone=constants.Filter.ping_everyone, ) ctx: Context = await self.bot.get_context(message) -- cgit v1.2.3 From 4480e1d5f7d2df5f9a2f8b57b1b9c4b26e1c8ea6 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Thu, 14 May 2020 10:03:30 +0200 Subject: Remove @Admins ping from the #verification message This probably isn't necessary anymore. We get so many new users that someone is going to DM us very soon when something breaks. We've outgrown this, and it just adds noise to the #verification channel in the form of pings. --- bot/cogs/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index b1ab7be4d..77e8b5706 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -40,7 +40,7 @@ else: PERIODIC_PING = ( f"@everyone To verify that you have read our rules, please type `{constants.Bot.prefix}accept`." " If you encounter any problems during the verification process, " - f"ping the <@&{constants.Roles.admins}> role in this channel." + f"send a direct message to a staff member." ) BOT_MESSAGE_DELETE_DELAY = 10 -- cgit v1.2.3 From 206aed70f6185057ccbe4f8478ed456e5ab0c197 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 14 May 2020 16:58:55 +0300 Subject: Python News: Implement stats Add stat increaser to PEP and maillist posting. --- bot/cogs/python_news.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bot/cogs/python_news.py b/bot/cogs/python_news.py index 57ce61638..d28af4a0b 100644 --- a/bot/cogs/python_news.py +++ b/bot/cogs/python_news.py @@ -109,6 +109,9 @@ class PythonNews(Cog): ) payload["data"]["pep"].append(pep_nr) + # Increase overall PEP new stat + self.bot.stats.incr("python_news.posted.pep") + if msg.channel.is_news(): log.trace("Publishing PEP annnouncement because it was in a news channel") await msg.publish() @@ -168,6 +171,9 @@ class PythonNews(Cog): ) payload["data"][maillist].append(thread_information["thread_id"]) + # Increase this specific maillist counter in stats + self.bot.stats.incr(f"python_news.posted.{maillist.replace('-', '_')}") + if msg.channel.is_news(): log.trace("Publishing mailing list message because it was in a news channel") await msg.publish() -- cgit v1.2.3 From d2c538e23c20c5c4b22d7b2eb2bcf03067593374 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Fri, 15 May 2020 19:33:24 +0200 Subject: Increase snekbox re eval timeout. --- bot/cogs/snekbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 8d4688114..611e80f61 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -227,7 +227,7 @@ class Snekbox(Cog): _, new_message = await self.bot.wait_for( 'message_edit', check=_predicate_eval_message_edit, - timeout=10 + timeout=30 ) await ctx.message.add_reaction(REEVAL_EMOJI) await self.bot.wait_for( -- cgit v1.2.3 From 1c06d2a9d873ced2e54bf16a96573a46c583c12f Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Fri, 15 May 2020 19:51:19 +0200 Subject: Move the re eval timeout to a module constant --- bot/cogs/snekbox.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 611e80f61..9fa75a929 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -47,6 +47,7 @@ EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles SIGKILL = 9 REEVAL_EMOJI = '\U0001f501' # :repeat: +REEVAL_TIMEOUT = 30 class Snekbox(Cog): @@ -227,7 +228,7 @@ class Snekbox(Cog): _, new_message = await self.bot.wait_for( 'message_edit', check=_predicate_eval_message_edit, - timeout=30 + timeout=REEVAL_TIMEOUT ) await ctx.message.add_reaction(REEVAL_EMOJI) await self.bot.wait_for( -- cgit v1.2.3 From 5a48ed0d60ebc9984cae27b19953b50b52df83d9 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Fri, 15 May 2020 19:52:26 +0200 Subject: Change tests to use the new timeout constant --- tests/bot/cogs/test_snekbox.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 1dec0ccaf..ccc090f02 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -291,7 +291,11 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(actual, expected) self.bot.wait_for.assert_has_awaits( ( - call('message_edit', check=partial_mock(snekbox.predicate_eval_message_edit, ctx), timeout=10), + call( + 'message_edit', + check=partial_mock(snekbox.predicate_eval_message_edit, ctx), + timeout=snekbox.REEVAL_TIMEOUT, + ), call('reaction_add', check=partial_mock(snekbox.predicate_eval_emoji_reaction, ctx), timeout=10) ) ) -- cgit v1.2.3 From c71c538c265e325f7aa4ee9cb9f298b2f50d38f1 Mon Sep 17 00:00:00 2001 From: mathsman5133 Date: Sat, 16 May 2020 13:11:25 +1000 Subject: fix redirect_output decorator; remove ninja code - Lots of instance of `for c in ...` or `for a in ...` or `fmt` which are non-descriptive and sometimes cryptic. - Ves suggested running the command in an asyncio task for `@redirect_output`, rather than making a workaround which only applies to the help command. This fixes a fundamental flaw where the redirection message wouldn't be deleted until a further 60sec after the command has finished, which for `!help` could be up to 5min, meaning the invocation message could be sitting there for 6min, not the intended 60sec. --- bot/cogs/help.py | 107 ++++++++++++++++++++++++++---------------------------- bot/decorators.py | 33 +++++++---------- bot/pagination.py | 2 +- 3 files changed, 66 insertions(+), 76 deletions(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 6fc4d83f8..542f19139 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -3,7 +3,7 @@ import logging from asyncio import TimeoutError from collections import namedtuple from contextlib import suppress -from typing import List +from typing import List, Union from discord import Colour, Embed, Member, Message, NotFound, Reaction, User from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand @@ -30,9 +30,9 @@ async def help_cleanup(bot: Bot, author: Member, message: Message) -> None: Adds the :trashcan: reaction that, when clicked, will delete the help message. After a 300 second timeout, the reaction will be removed. """ - def check(r: Reaction, u: User) -> bool: + def check(reaction: Reaction, user: User) -> bool: """Checks the reaction is :trashcan:, the author is original author and messages are the same.""" - return str(r) == DELETE_EMOJI and u.id == author.id and r.message.id == message.id + return str(reaction) == DELETE_EMOJI and user.id == author.id and reaction.message.id == message.id await message.add_reaction(DELETE_EMOJI) @@ -75,17 +75,11 @@ class CustomHelpCommand(HelpCommand): super().__init__(command_attrs={"help": "Shows help for bot commands"}) @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES) - async def prepare_help_command(self, ctx: Context, command: str = None) -> None: - """Adjust context to redirect to a new channel if required.""" - self.context = ctx - async def command_callback(self, ctx: Context, *, command: str = None) -> None: """Attempts to match the provided query with a valid command or cog.""" # the only reason we need to tamper with this is because d.py does not support "categories", # so we need to deal with them ourselves. - # handle any command redirection and adjust context channel accordingly. - await self.prepare_help_command(ctx, command=command) bot = ctx.bot if command is None: @@ -107,7 +101,7 @@ class CustomHelpCommand(HelpCommand): await self.send_category_help(category) return - # it's either a cog, group, command or subcommand, let super deal with it + # it's either a cog, group, command or subcommand; let the parent class deal with it await super().command_callback(ctx, command=command) async def get_all_help_choices(self) -> set: @@ -127,22 +121,22 @@ class CustomHelpCommand(HelpCommand): """ # first get all commands including subcommands and full command name aliases choices = set() - for c in await self.filter_commands(self.context.bot.walk_commands()): + for command in await self.filter_commands(self.context.bot.walk_commands()): # the the command or group name - choices.add(str(c)) + choices.add(str(command)) - if isinstance(c, Command): + if isinstance(command, Command): # all aliases if it's just a command - choices.update(c.aliases) + choices.update(command.aliases) else: # otherwise we need to add the parent name in - choices.update(f"{c.full_parent_name} {a}" for a in c.aliases) + choices.update(f"{command.full_parent_name} {alias}" for alias in command.aliases) # all cog names choices.update(self.context.bot.cogs) # all category names - choices.update(n.category for n in self.context.bot.cogs.values() if hasattr(n, "category")) + choices.update(cog.category for cog in self.context.bot.cogs.values() if hasattr(cog, "category")) return choices async def command_not_found(self, string: str) -> "HelpQueryNotFound": @@ -169,7 +163,7 @@ class CustomHelpCommand(HelpCommand): embed = Embed(colour=Colour.red(), title=str(error)) if getattr(error, "possible_matches", None): - matches = "\n".join(f"`{n}`" for n in error.possible_matches) + matches = "\n".join(f"`{match}`" for match in error.possible_matches) embed.description = f"**Did you mean:**\n{matches}" await self.context.send(embed=embed) @@ -186,19 +180,19 @@ class CustomHelpCommand(HelpCommand): parent = command.full_parent_name name = str(command) if not parent else f"{parent} {command.name}" - fmt = f"**```{PREFIX}{name} {command.signature}```**\n" + command_details = f"**```{PREFIX}{name} {command.signature}```**\n" # show command aliases - aliases = ", ".join(f"`{a}`" if not parent else f"`{parent} {a}`" for a in command.aliases) + aliases = ", ".join(f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases) if aliases: - fmt += f"**Can also use:** {aliases}\n\n" + command_details += f"**Can also use:** {aliases}\n\n" # check if the user is allowed to run this command if not await command.can_run(self.context): - fmt += "***You cannot run this command.***\n\n" + command_details += "***You cannot run this command.***\n\n" - fmt += f"*{command.help or 'No details provided.'}*\n" - embed.description = fmt + command_details += f"*{command.help or 'No details provided.'}*\n" + embed.description = command_details return embed @@ -209,14 +203,22 @@ class CustomHelpCommand(HelpCommand): await help_cleanup(self.context.bot, self.context.author, message) @staticmethod - def get_commands_brief_details(commands_: List[Command]) -> str: - """Formats the prefix, command name and signature, and short doc for an iterable of commands.""" - details = "" - for c in commands_: - signature = f" {c.signature}" if c.signature else "" - details += f"\n**`{PREFIX}{c.qualified_name}{signature}`**\n*{c.short_doc or 'No details provided'}*" + def get_commands_brief_details(commands_: List[Command], return_as_list: bool = False) -> Union[List[str], str]: + """ + Formats the prefix, command name and signature, and short doc for an iterable of commands. - return details + return_as_list is helpful for passing these command details into the paginator as a list of command details. + """ + details = [] + for command in commands_: + signature = f" {command.signature}" if command.signature else "" + details.append( + f"\n**`{PREFIX}{command.qualified_name}{signature}`**\n*{command.short_doc or 'No details provided'}*" + ) + if return_as_list: + return details + else: + return "".join(details) async def send_group_help(self, group: Group) -> None: """Sends help for a group command.""" @@ -256,17 +258,17 @@ class CustomHelpCommand(HelpCommand): await help_cleanup(self.context.bot, self.context.author, message) @staticmethod - def _category_key(cmd: Command) -> str: + def _category_key(command: Command) -> str: """ Returns a cog name of a given command for use as a key for `sorted` and `groupby`. A zero width space is used as a prefix for results with no cogs to force them last in ordering. """ - if cmd.cog: + if command.cog: with suppress(AttributeError): - if cmd.cog.category: - return f"**{cmd.cog.category}**" - return f"**{cmd.cog_name}**" + if command.cog.category: + return f"**{command.cog.category}**" + return f"**{command.cog_name}**" else: return "**\u200bNo Category:**" @@ -280,28 +282,24 @@ class CustomHelpCommand(HelpCommand): embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) all_commands = [] - for c in category.cogs: - all_commands.extend(c.get_commands()) + for cog in category.cogs: + all_commands.extend(cog.get_commands()) filtered_commands = await self.filter_commands(all_commands, sort=True) - lines = [ - f"`{PREFIX}{c.qualified_name}{f' {c.signature}' if c.signature else ''}`" - f"\n*{c.short_doc or 'No details provided.'}*" for c in filtered_commands - ] - + command_detail_lines = self.get_commands_brief_details(filtered_commands, return_as_list=True) description = f"**{category.name}**\n*{category.description}*" - if lines: + if command_detail_lines: description += "\n\n**Commands:**" await LinePaginator.paginate( - lines, + command_detail_lines, self.context, embed, prefix=description, max_lines=COMMANDS_PER_PAGE, - max_size=2040 + max_size=2040, ) async def send_bot_help(self, mapping: dict) -> None: @@ -321,31 +319,28 @@ class CustomHelpCommand(HelpCommand): if len(sorted_commands) == 0: continue - command_details = [ - f"`{PREFIX}{c.qualified_name}{f' {c.signature}' if c.signature else ''}`" - f"\n*{c.short_doc or 'No details provided.'}*" for c in sorted_commands - ] + command_detail_lines = self.get_commands_brief_details(sorted_commands, return_as_list=True) # Split cogs or categories which have too many commands to fit in one page. # The length of commands is included for later use when aggregating into pages for the paginator. - for i in range(0, len(sorted_commands), COMMANDS_PER_PAGE): - truncated_fmt = command_details[i:i + COMMANDS_PER_PAGE] - joined_fmt = "\n".join(truncated_fmt) - cog_or_category_pages.append((f"**{cog_or_category}**\n{joined_fmt}", len(truncated_fmt))) + for index in range(0, len(sorted_commands), COMMANDS_PER_PAGE): + truncated_lines = command_detail_lines[index:index + COMMANDS_PER_PAGE] + joined_lines = "".join(truncated_lines) + cog_or_category_pages.append((f"**{cog_or_category}**{joined_lines}", len(truncated_lines))) pages = [] counter = 0 page = "" - for fmt, length in cog_or_category_pages: + for page_details, length in cog_or_category_pages: counter += length if counter > COMMANDS_PER_PAGE: # force a new page on paginator even if it falls short of the max pages # since we still want to group categories/cogs. counter = length pages.append(page) - page = f"{fmt}\n\n" + page = f"{page_details}\n\n" else: - page += f"{fmt}\n\n" + page += f"{page_details}\n\n" if page: # add any remaining command help that didn't get added in the last iteration above. diff --git a/bot/decorators.py b/bot/decorators.py index ce38a5e76..306f0830c 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,12 +1,12 @@ import logging import random -from asyncio import Lock, sleep +from asyncio import Lock, create_task, sleep from contextlib import suppress from functools import wraps from typing import Callable, Container, Optional, Union from weakref import WeakValueDictionary -from discord import Colour, Embed, Member, Message +from discord import Colour, Embed, Member from discord.errors import NotFound from discord.ext import commands from discord.ext.commands import CheckFailure, Cog, Context @@ -135,20 +135,6 @@ def locked() -> Callable: return wrap -async def delete_invocation(ctx: Context, message: Message) -> None: - """Task to delete the invocation and user redirection messages.""" - if RedirectOutput.delete_invocation: - await sleep(RedirectOutput.delete_delay) - - with suppress(NotFound): - await message.delete() - log.trace("Redirect output: Deleted user redirection message") - - with suppress(NotFound): - await ctx.message.delete() - log.trace("Redirect output: Deleted invocation message") - - def redirect_output(destination_channel: int, bypass_roles: Container[int] = None) -> Callable: """ Changes the channel in the context of the command to redirect the output to a certain channel. @@ -176,14 +162,23 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non log.trace(f"Redirecting output of {ctx.author}'s command '{ctx.command.name}' to {redirect_channel.name}") ctx.channel = redirect_channel await ctx.channel.send(f"Here's the output of your command, {ctx.author.mention}") - await func(self, ctx, *args, **kwargs) + create_task(func(self, ctx, *args, **kwargs)) message = await old_channel.send( f"Hey, {ctx.author.mention}, you can find the output of your command here: " f"{redirect_channel.mention}" ) - # we need to run it in a task for the help command - which gets held up if waiting for invocation deletion. - ctx.bot.loop.create_task(delete_invocation(ctx, message)) + if RedirectOutput.delete_invocation: + await sleep(RedirectOutput.delete_delay) + + with suppress(NotFound): + await message.delete() + log.trace("Redirect output: Deleted user redirection message") + + with suppress(NotFound): + await ctx.message.delete() + log.trace("Redirect output: Deleted invocation message") + return inner return wrap diff --git a/bot/pagination.py b/bot/pagination.py index 90c8f849c..b0c4b70e2 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -102,7 +102,7 @@ class LinePaginator(Paginator): timeout: int = 300, footer_text: str = None, url: str = None, - exception_on_empty_embed: bool = False + exception_on_empty_embed: bool = False, ) -> t.Optional[discord.Message]: """ Use a paginator and set of reactions to provide pagination over a set of lines. -- cgit v1.2.3 From 93cce50846b1cfcf520535d69a9fe223c2cd4d7a Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 16 May 2020 16:49:48 +0300 Subject: Stats: Create guild boost stat collection Collect Guild boost amount + level and post it to StatsD every hour in task. Added starting to cog `__init__.py` and stopping to `cog_unload`. --- bot/cogs/stats.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/bot/cogs/stats.py b/bot/cogs/stats.py index d253db913..acee1f5a9 100644 --- a/bot/cogs/stats.py +++ b/bot/cogs/stats.py @@ -2,8 +2,10 @@ import string from datetime import datetime from discord import Member, Message, Status -from discord.ext.commands import Bot, Cog, Context +from discord.ext.commands import Cog, Context +from discord.ext.tasks import loop +from bot.bot import Bot from bot.constants import Channels, Guild, Stats as StatConf @@ -23,6 +25,7 @@ class Stats(Cog): def __init__(self, bot: Bot): self.bot = bot self.last_presence_update = None + self.update_guild_boost.start() @Cog.listener() async def on_message(self, message: Message) -> None: @@ -101,6 +104,18 @@ class Stats(Cog): self.bot.stats.gauge("guild.status.do_not_disturb", dnd) self.bot.stats.gauge("guild.status.offline", offline) + @loop(hours=1) + async def update_guild_boost(self) -> None: + """Update every hour guild boosts amount + level.""" + await self.bot.wait_until_guild_available() + g = self.bot.get_guild(Guild.id) + self.bot.stats.gauge("boost.amount", g.premium_subscription_count) + self.bot.stats.gauge("boost.tier", g.premium_tier) + + def cog_unload(self) -> None: + """Stop guild boost stat collecting task on Cog unload.""" + self.update_guild_boost.stop() + def setup(bot: Bot) -> None: """Load the stats cog.""" -- cgit v1.2.3 From 1a6abaac12eb2e6ab0d26065108fe1cce9a7be45 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 16 May 2020 17:00:25 +0300 Subject: Stats: Added codeblock correction stats --- bot/cogs/bot.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index a6929b431..67ff8f95d 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -326,6 +326,8 @@ class BotCog(Cog, name="Bot"): log.trace("The code consists only of expressions, not sending instructions") if howto != "": + # Increase amount of codeblock correction in stats + self.bot.stats.incr("codeblock_corrections") howto_embed = Embed(description=howto) bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed) self.codeblock_message_ids[msg.id] = bot_message.id -- cgit v1.2.3 From 158e19a6fcb2056c6bcc244a1f02d8b75d7fe503 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 16 May 2020 17:13:54 +0300 Subject: Stats: Added stats for eval successes + fails --- bot/cogs/snekbox.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 8d4688114..1c64c893b 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -205,6 +205,12 @@ class Snekbox(Cog): if paste_link: msg = f"{msg}\nFull output: {paste_link}" + # Collect stats of eval fails + successes + if icon == ":x:": + self.bot.stats.incr("evals.fail") + elif icon in (":warning:", ":white_check_mark:"): + self.bot.stats.incr("evals.success") + response = await ctx.send(msg) self.bot.loop.create_task( wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) -- cgit v1.2.3 From 5878ec93c5b883038b0f738e9bbbdee8fd1929ad Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 16 May 2020 17:25:25 +0300 Subject: Stats: Added stats for eval role uses (Helpers/Developers) --- bot/cogs/snekbox.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 1c64c893b..04c0a5ae1 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -298,6 +298,11 @@ class Snekbox(Cog): await ctx.invoke(self.bot.get_command("help"), "eval") return + if Roles.helpers in (role.id for role in ctx.author.roles): + self.bot.stats.incr("evals.roles.helpers") + else: + self.bot.stats.incr("evals.roles.developers") + log.info(f"Received code from {ctx.author} for evaluation:\n{code}") while True: -- cgit v1.2.3 From 613b00a5ec060f409d5838cb1a648d9770cecfde Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 16 May 2020 18:19:18 +0300 Subject: Stats: Added stats for eval channel using (Help/Bot commands/Topical) --- bot/cogs/snekbox.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 04c0a5ae1..1d240d8d8 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -303,6 +303,13 @@ class Snekbox(Cog): else: self.bot.stats.incr("evals.roles.developers") + if ctx.channel.category_id == Categories.help_in_use: + self.bot.stats.incr("evals.channels.help") + elif ctx.channel.id == Channels.bot_commands: + self.bot.stats.incr("evals.channels.bot_commands") + else: + self.bot.stats.incr("evals.channels.topical") + log.info(f"Received code from {ctx.author} for evaluation:\n{code}") while True: -- cgit v1.2.3 From 3a278c7ff64951e0bb7ed05d822f60e5a70d34d9 Mon Sep 17 00:00:00 2001 From: vivax3794 <51753506+vivax3794@users.noreply.github.com> Date: Sun, 17 May 2020 00:38:17 +0200 Subject: added "solved" as a alias for "closed" --- bot/cogs/help_channels.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index b714a1642..1bd1f9d68 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -225,7 +225,7 @@ class HelpChannels(Scheduler, commands.Cog): return role_check - @commands.command(name="close", aliases=["dormant"], enabled=False) + @commands.command(name="close", aliases=["dormant", "solved"], enabled=False) async def close_command(self, ctx: commands.Context) -> None: """ Make the current in-use help channel dormant. -- cgit v1.2.3 From 4e24e9c43a331ebc0f9b598f4de6c45e04216782 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sun, 17 May 2020 12:10:56 +0200 Subject: Use `send_help` to invoke command help After the refactoring of the help command, we need to use the built-in method of calling the help command: `Context.send_help`. As an argument, the qualified name (a string containing the full command path, including parents) of the command can be passed. Examples: - await ctx.send_help("reminders edit") This would send a help embed with information on `!reminders edit` to the Context. - await ctx.send_help(ctx.command.qualified_name) This would extract the qualified name of the command, which is the full command path, and send a help embed to Context. - await ctx.send_help() This will send the main "root" help embed to the Context. --- bot/cogs/bot.py | 2 +- bot/cogs/clean.py | 2 +- bot/cogs/defcon.py | 2 +- bot/cogs/error_handler.py | 33 +++++++++++++-------------------- bot/cogs/eval.py | 2 +- bot/cogs/extensions.py | 8 ++++---- bot/cogs/moderation/management.py | 2 +- bot/cogs/off_topic_names.py | 2 +- bot/cogs/reddit.py | 2 +- bot/cogs/reminders.py | 2 +- bot/cogs/site.py | 2 +- bot/cogs/snekbox.py | 2 +- bot/cogs/utils.py | 2 +- bot/cogs/watchchannels/bigbrother.py | 2 +- bot/cogs/watchchannels/talentpool.py | 4 ++-- tests/bot/cogs/test_snekbox.py | 3 +-- 16 files changed, 32 insertions(+), 40 deletions(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index a6929b431..ae829d5c3 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -41,7 +41,7 @@ class BotCog(Cog, name="Bot"): @with_role(Roles.verified) async def botinfo_group(self, ctx: Context) -> None: """Bot informational commands.""" - await ctx.invoke(self.bot.get_command("help"), "bot") + await ctx.send_help("bot") @botinfo_group.command(name='about', aliases=('info',), hidden=True) @with_role(Roles.verified) diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index 5cdf0b048..e9bdbf510 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -180,7 +180,7 @@ class Clean(Cog): @with_role(*MODERATION_ROLES) async def clean_group(self, ctx: Context) -> None: """Commands for cleaning messages in channels.""" - await ctx.invoke(self.bot.get_command("help"), "clean") + await ctx.send_help("clean") @clean_group.command(name="user", aliases=["users"]) @with_role(*MODERATION_ROLES) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 56fca002a..71847a441 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -122,7 +122,7 @@ class Defcon(Cog): @with_role(Roles.admins, Roles.owners) async def defcon_group(self, ctx: Context) -> None: """Check the DEFCON status or run a subcommand.""" - await ctx.invoke(self.bot.get_command("help"), "defcon") + await ctx.send_help("defcon") async def _defcon_action(self, ctx: Context, days: int, action: Action) -> None: """Providing a structured way to do an defcon action.""" diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index b2f4c59f6..2d6cd85e6 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -2,7 +2,7 @@ import contextlib import logging import typing as t -from discord.ext.commands import Cog, Command, Context, errors +from discord.ext.commands import Cog, Context, errors from sentry_sdk import push_scope from bot.api import ResponseCodeError @@ -79,19 +79,13 @@ class ErrorHandler(Cog): f"{e.__class__.__name__}: {e}" ) - async def get_help_command(self, command: t.Optional[Command]) -> t.Tuple: - """Return the help command invocation args to display help for `command`.""" - parent = None - if command is not None: - parent = command.parent - - # Retrieve the help command for the invoked command. - if parent and command: - return self.bot.get_command("help"), parent.name, command.name - elif command: - return self.bot.get_command("help"), command.name - else: - return self.bot.get_command("help") + @staticmethod + def get_help_command(ctx: Context) -> t.Coroutine: + """Return a prepared `help` command invocation coroutine.""" + if ctx.command: + return ctx.send_help(ctx.command.qualified_name) + + return ctx.send_help() async def try_silence(self, ctx: Context) -> bool: """ @@ -165,20 +159,19 @@ class ErrorHandler(Cog): * ArgumentParsingError: send an error message * Other: send an error message and the help command """ - # TODO: use ctx.send_help() once PR #519 is merged. - help_command = await self.get_help_command(ctx.command) + prepared_help_command = self.get_help_command(ctx) if isinstance(e, errors.MissingRequiredArgument): await ctx.send(f"Missing required argument `{e.param.name}`.") - await ctx.invoke(*help_command) + await prepared_help_command self.bot.stats.incr("errors.missing_required_argument") elif isinstance(e, errors.TooManyArguments): await ctx.send(f"Too many arguments provided.") - await ctx.invoke(*help_command) + await prepared_help_command self.bot.stats.incr("errors.too_many_arguments") elif isinstance(e, errors.BadArgument): await ctx.send(f"Bad argument: {e}\n") - await ctx.invoke(*help_command) + await prepared_help_command self.bot.stats.incr("errors.bad_argument") elif isinstance(e, errors.BadUnionArgument): await ctx.send(f"Bad argument: {e}\n```{e.errors[-1]}```") @@ -188,7 +181,7 @@ class ErrorHandler(Cog): self.bot.stats.incr("errors.argument_parsing_error") else: await ctx.send("Something about your input seems off. Check the arguments:") - await ctx.invoke(*help_command) + await prepared_help_command self.bot.stats.incr("errors.other_user_input_error") @staticmethod diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 52136fc8d..2d52197e8 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -178,7 +178,7 @@ async def func(): # (None,) -> Any async def internal_group(self, ctx: Context) -> None: """Internal commands. Top secret!""" if not ctx.invoked_subcommand: - await ctx.invoke(self.bot.get_command("help"), "internal") + await ctx.send_help("internal") @internal_group.command(name='eval', aliases=('e',)) @with_role(Roles.admins, Roles.owners) diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index fb6cd9aa3..4493046e1 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -65,7 +65,7 @@ class Extensions(commands.Cog): @group(name="extensions", aliases=("ext", "exts", "c", "cogs"), invoke_without_command=True) async def extensions_group(self, ctx: Context) -> None: """Load, unload, reload, and list loaded extensions.""" - await ctx.invoke(self.bot.get_command("help"), "extensions") + await ctx.send_help("extensions") @extensions_group.command(name="load", aliases=("l",)) async def load_command(self, ctx: Context, *extensions: Extension) -> None: @@ -75,7 +75,7 @@ class Extensions(commands.Cog): If '\*' or '\*\*' is given as the name, all unloaded extensions will be loaded. """ # noqa: W605 if not extensions: - await ctx.invoke(self.bot.get_command("help"), "extensions load") + await ctx.send_help("extensions load") return if "*" in extensions or "**" in extensions: @@ -92,7 +92,7 @@ class Extensions(commands.Cog): If '\*' or '\*\*' is given as the name, all loaded extensions will be unloaded. """ # noqa: W605 if not extensions: - await ctx.invoke(self.bot.get_command("help"), "extensions unload") + await ctx.send_help("extensions unload") return blacklisted = "\n".join(UNLOAD_BLACKLIST & set(extensions)) @@ -118,7 +118,7 @@ class Extensions(commands.Cog): If '\*\*' is given as the name, all extensions, including unloaded ones, will be reloaded. """ # noqa: W605 if not extensions: - await ctx.invoke(self.bot.get_command("help"), "extensions reload") + await ctx.send_help("extensions reload") return if "**" in extensions: diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 250a24247..5cd59cc07 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -43,7 +43,7 @@ class ModManagement(commands.Cog): @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True) async def infraction_group(self, ctx: Context) -> None: """Infraction manipulation commands.""" - await ctx.invoke(self.bot.get_command("help"), "infraction") + await ctx.send_help("infraction") @infraction_group.command(name='edit') async def infraction_edit( diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 81511f99d..829772f65 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -97,7 +97,7 @@ class OffTopicNames(Cog): @with_role(*MODERATION_ROLES) async def otname_group(self, ctx: Context) -> None: """Add or list items from the off-topic channel name rotation.""" - await ctx.invoke(self.bot.get_command("help"), "otname") + await ctx.send_help("otname") @otname_group.command(name='add', aliases=('a',)) @with_role(*MODERATION_ROLES) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 5a7fa100f..07a2497be 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -245,7 +245,7 @@ class Reddit(Cog): @group(name="reddit", invoke_without_command=True) async def reddit_group(self, ctx: Context) -> None: """View the top posts from various subreddits.""" - await ctx.invoke(self.bot.get_command("help"), "reddit") + await ctx.send_help("reddit") @reddit_group.command(name="top") async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 8b6457cbb..e2289c75d 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -281,7 +281,7 @@ class Reminders(Scheduler, Cog): @remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True) async def edit_reminder_group(self, ctx: Context) -> None: """Commands for modifying your current reminders.""" - await ctx.invoke(self.bot.get_command("help"), "reminders", "edit") + await ctx.send_help("reminders edit") @edit_reminder_group.command(name="duration", aliases=("time",)) async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: Duration) -> None: diff --git a/bot/cogs/site.py b/bot/cogs/site.py index 853e29568..c17761a2b 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -21,7 +21,7 @@ class Site(Cog): @group(name="site", aliases=("s",), invoke_without_command=True) async def site_group(self, ctx: Context) -> None: """Commands for getting info about our website.""" - await ctx.invoke(self.bot.get_command("help"), "site") + await ctx.send_help("site") @site_group.command(name="home", aliases=("about",)) async def site_main(self, ctx: Context) -> None: diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 8d4688114..5de978758 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -289,7 +289,7 @@ class Snekbox(Cog): return if not code: # None or empty string - await ctx.invoke(self.bot.get_command("help"), "eval") + await ctx.send_help("eval") return log.info(f"Received code from {ctx.author} for evaluation:\n{code}") diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 89d556f58..7350dc2ba 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -55,7 +55,7 @@ class Utils(Cog): if pep_number.isdigit(): pep_number = int(pep_number) else: - await ctx.invoke(self.bot.get_command("help"), "pep") + await ctx.send_help("pep") return # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 903c87f85..37f2d2b9d 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -30,7 +30,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): @with_role(*MODERATION_ROLES) async def bigbrother_group(self, ctx: Context) -> None: """Monitors users by relaying their messages to the Big Brother watch channel.""" - await ctx.invoke(self.bot.get_command("help"), "bigbrother") + await ctx.send_help("bigbrother") @bigbrother_group.command(name='watched', aliases=('all', 'list')) @with_role(*MODERATION_ROLES) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index ad0c51fa6..b8473963d 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -34,7 +34,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @with_role(*MODERATION_ROLES) async def nomination_group(self, ctx: Context) -> None: """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" - await ctx.invoke(self.bot.get_command("help"), "talentpool") + await ctx.send_help("talentpool") @nomination_group.command(name='watched', aliases=('all', 'list')) @with_role(*MODERATION_ROLES) @@ -173,7 +173,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @with_role(*MODERATION_ROLES) async def nomination_edit_group(self, ctx: Context) -> None: """Commands to edit nominations.""" - await ctx.invoke(self.bot.get_command("help"), "talentpool", "edit") + await ctx.send_help("talentpool edit") @nomination_edit_group.command(name='reason') @with_role(*MODERATION_ROLES) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 1dec0ccaf..190d41d66 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -209,9 +209,8 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): async def test_eval_command_call_help(self): """Test if the eval command call the help command if no code is provided.""" ctx = MockContext() - ctx.invoke = AsyncMock() await self.cog.eval_command.callback(self.cog, ctx=ctx, code='') - ctx.invoke.assert_called_once_with(self.bot.get_command("help"), "eval") + ctx.send_help.assert_called_once_with("eval") async def test_send_eval(self): """Test the send_eval function.""" -- cgit v1.2.3 From a983d49051dad22a041c0c2f886313e38e0eaf61 Mon Sep 17 00:00:00 2001 From: decorator-factory <42166884+decorator-factory@users.noreply.github.com> Date: Sun, 17 May 2020 13:28:41 +0300 Subject: Apply language improvements proposed from kwzrd Co-authored-by: kwzrd <44734341+kwzrd@users.noreply.github.com> --- bot/resources/tags/mutability.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/resources/tags/mutability.md b/bot/resources/tags/mutability.md index 48e5bac74..b37420fc7 100644 --- a/bot/resources/tags/mutability.md +++ b/bot/resources/tags/mutability.md @@ -11,14 +11,14 @@ print(string) # abcd `string` didn't change. Why is that so? -That's because strings in Python are _immutable_. You can't change them, you can only pass around existing strings or create new oness. +That's because strings in Python are _immutable_. You can't change them, you can only pass around existing strings or create new ones. ```python string = "abcd" string = string.upper() ``` -`string.upper()` creates a new string which is like the old one, but with allthe letters turned to upper case. +`string.upper()` creates and returns a new string which is like the old one, but with all the letters turned to upper case. `int`, `float`, `complex`, `tuple`, `frozenset` are other examples of immutable data types in Python. @@ -29,4 +29,4 @@ my_list.append(4) print(my_list) # [1, 2, 3, 4] ``` -`dict` and `set` are other examples of mutable data types in Python. +Other examples of mutable data types in Python are `dict` and `set`. -- cgit v1.2.3 From 1db4fe2cfe63a9199eb84f8c0ee6daff6efa41dc Mon Sep 17 00:00:00 2001 From: decorator-factory <42166884+decorator-factory@users.noreply.github.com> Date: Sun, 17 May 2020 13:30:23 +0300 Subject: Change standalone programs to interactive sessions --- bot/resources/tags/mutability.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/bot/resources/tags/mutability.md b/bot/resources/tags/mutability.md index b37420fc7..8b98da43a 100644 --- a/bot/resources/tags/mutability.md +++ b/bot/resources/tags/mutability.md @@ -4,9 +4,11 @@ Imagine that you want to make all letters in a string upper case. Conveniently, You might think that this would work: ```python -string = "abcd" -string.upper() -print(string) # abcd +>>> string = "abcd" +>>> string.upper() +'ABCD' +>>> string +'abcd' ``` `string` didn't change. Why is that so? @@ -14,8 +16,10 @@ print(string) # abcd That's because strings in Python are _immutable_. You can't change them, you can only pass around existing strings or create new ones. ```python -string = "abcd" -string = string.upper() +>>> string = "abcd" +>>> string = string.upper() +>>> string +'ABCD' ``` `string.upper()` creates and returns a new string which is like the old one, but with all the letters turned to upper case. @@ -24,9 +28,10 @@ string = string.upper() Mutable data types like `list`, on the other hand, can be changed in-place: ```python -my_list = [1, 2, 3] -my_list.append(4) -print(my_list) # [1, 2, 3, 4] +>>> my_list = [1, 2, 3] +>>> my_list.append(4) +>>> my_list +[1, 2, 3, 4] ``` Other examples of mutable data types in Python are `dict` and `set`. -- cgit v1.2.3 From abb9e57f7022d87f762660d10c83b7912483b873 Mon Sep 17 00:00:00 2001 From: decorator-factory <42166884+decorator-factory@users.noreply.github.com> Date: Sun, 17 May 2020 13:34:27 +0300 Subject: Add a note on user-defined classes --- bot/resources/tags/mutability.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/mutability.md b/bot/resources/tags/mutability.md index 8b98da43a..3447109ad 100644 --- a/bot/resources/tags/mutability.md +++ b/bot/resources/tags/mutability.md @@ -34,4 +34,4 @@ Mutable data types like `list`, on the other hand, can be changed in-place: [1, 2, 3, 4] ``` -Other examples of mutable data types in Python are `dict` and `set`. +Other examples of mutable data types in Python are `dict` and `set`. Instances of user-defined classes are also mutable. -- cgit v1.2.3 From 87cec1a863213aa23a07d29ca928766d382ee732 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sun, 17 May 2020 12:37:34 +0200 Subject: Use `Command`-object for `send_help` As @mathsman5133 pointed out, it's better to use the `Command`-instance we typically already have in the current context than to rely on parsing the qualified name again. The invocation is now done as: `await ctx.send_help(ctx.command)` --- bot/cogs/bot.py | 2 +- bot/cogs/clean.py | 2 +- bot/cogs/defcon.py | 2 +- bot/cogs/error_handler.py | 2 +- bot/cogs/eval.py | 2 +- bot/cogs/extensions.py | 8 ++++---- bot/cogs/moderation/management.py | 2 +- bot/cogs/off_topic_names.py | 2 +- bot/cogs/reddit.py | 2 +- bot/cogs/reminders.py | 2 +- bot/cogs/site.py | 2 +- bot/cogs/snekbox.py | 2 +- bot/cogs/utils.py | 2 +- bot/cogs/watchchannels/bigbrother.py | 2 +- bot/cogs/watchchannels/talentpool.py | 4 ++-- tests/bot/cogs/test_snekbox.py | 4 ++-- 16 files changed, 21 insertions(+), 21 deletions(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index ae829d5c3..f6aea51c5 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -41,7 +41,7 @@ class BotCog(Cog, name="Bot"): @with_role(Roles.verified) async def botinfo_group(self, ctx: Context) -> None: """Bot informational commands.""" - await ctx.send_help("bot") + await ctx.send_help(ctx.command) @botinfo_group.command(name='about', aliases=('info',), hidden=True) @with_role(Roles.verified) diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index e9bdbf510..b5d9132cb 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -180,7 +180,7 @@ class Clean(Cog): @with_role(*MODERATION_ROLES) async def clean_group(self, ctx: Context) -> None: """Commands for cleaning messages in channels.""" - await ctx.send_help("clean") + await ctx.send_help(ctx.command) @clean_group.command(name="user", aliases=["users"]) @with_role(*MODERATION_ROLES) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 71847a441..25b0a6ad5 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -122,7 +122,7 @@ class Defcon(Cog): @with_role(Roles.admins, Roles.owners) async def defcon_group(self, ctx: Context) -> None: """Check the DEFCON status or run a subcommand.""" - await ctx.send_help("defcon") + await ctx.send_help(ctx.command) async def _defcon_action(self, ctx: Context, days: int, action: Action) -> None: """Providing a structured way to do an defcon action.""" diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 2d6cd85e6..23d1eed82 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -83,7 +83,7 @@ class ErrorHandler(Cog): def get_help_command(ctx: Context) -> t.Coroutine: """Return a prepared `help` command invocation coroutine.""" if ctx.command: - return ctx.send_help(ctx.command.qualified_name) + return ctx.send_help(ctx.command) return ctx.send_help() diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 2d52197e8..eb8bfb1cf 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -178,7 +178,7 @@ async def func(): # (None,) -> Any async def internal_group(self, ctx: Context) -> None: """Internal commands. Top secret!""" if not ctx.invoked_subcommand: - await ctx.send_help("internal") + await ctx.send_help(ctx.command) @internal_group.command(name='eval', aliases=('e',)) @with_role(Roles.admins, Roles.owners) diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index 4493046e1..365f198ff 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -65,7 +65,7 @@ class Extensions(commands.Cog): @group(name="extensions", aliases=("ext", "exts", "c", "cogs"), invoke_without_command=True) async def extensions_group(self, ctx: Context) -> None: """Load, unload, reload, and list loaded extensions.""" - await ctx.send_help("extensions") + await ctx.send_help(ctx.command) @extensions_group.command(name="load", aliases=("l",)) async def load_command(self, ctx: Context, *extensions: Extension) -> None: @@ -75,7 +75,7 @@ class Extensions(commands.Cog): If '\*' or '\*\*' is given as the name, all unloaded extensions will be loaded. """ # noqa: W605 if not extensions: - await ctx.send_help("extensions load") + await ctx.send_help(ctx.command) return if "*" in extensions or "**" in extensions: @@ -92,7 +92,7 @@ class Extensions(commands.Cog): If '\*' or '\*\*' is given as the name, all loaded extensions will be unloaded. """ # noqa: W605 if not extensions: - await ctx.send_help("extensions unload") + await ctx.send_help(ctx.command) return blacklisted = "\n".join(UNLOAD_BLACKLIST & set(extensions)) @@ -118,7 +118,7 @@ class Extensions(commands.Cog): If '\*\*' is given as the name, all extensions, including unloaded ones, will be reloaded. """ # noqa: W605 if not extensions: - await ctx.send_help("extensions reload") + await ctx.send_help(ctx.command) return if "**" in extensions: diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 5cd59cc07..edfdfd9e2 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -43,7 +43,7 @@ class ModManagement(commands.Cog): @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True) async def infraction_group(self, ctx: Context) -> None: """Infraction manipulation commands.""" - await ctx.send_help("infraction") + await ctx.send_help(ctx.command) @infraction_group.command(name='edit') async def infraction_edit( diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 829772f65..201579a0b 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -97,7 +97,7 @@ class OffTopicNames(Cog): @with_role(*MODERATION_ROLES) async def otname_group(self, ctx: Context) -> None: """Add or list items from the off-topic channel name rotation.""" - await ctx.send_help("otname") + await ctx.send_help(ctx.command) @otname_group.command(name='add', aliases=('a',)) @with_role(*MODERATION_ROLES) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 07a2497be..5f2aec7a5 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -245,7 +245,7 @@ class Reddit(Cog): @group(name="reddit", invoke_without_command=True) async def reddit_group(self, ctx: Context) -> None: """View the top posts from various subreddits.""" - await ctx.send_help("reddit") + await ctx.send_help(ctx.command) @reddit_group.command(name="top") async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index e2289c75d..c242d2920 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -281,7 +281,7 @@ class Reminders(Scheduler, Cog): @remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True) async def edit_reminder_group(self, ctx: Context) -> None: """Commands for modifying your current reminders.""" - await ctx.send_help("reminders edit") + await ctx.send_help(ctx.command) @edit_reminder_group.command(name="duration", aliases=("time",)) async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: Duration) -> None: diff --git a/bot/cogs/site.py b/bot/cogs/site.py index c17761a2b..7fc2a9c34 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -21,7 +21,7 @@ class Site(Cog): @group(name="site", aliases=("s",), invoke_without_command=True) async def site_group(self, ctx: Context) -> None: """Commands for getting info about our website.""" - await ctx.send_help("site") + await ctx.send_help(ctx.command) @site_group.command(name="home", aliases=("about",)) async def site_main(self, ctx: Context) -> None: diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 5de978758..c2782b9c8 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -289,7 +289,7 @@ class Snekbox(Cog): return if not code: # None or empty string - await ctx.send_help("eval") + await ctx.send_help(ctx.command) return log.info(f"Received code from {ctx.author} for evaluation:\n{code}") diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 7350dc2ba..6b59d37c8 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -55,7 +55,7 @@ class Utils(Cog): if pep_number.isdigit(): pep_number = int(pep_number) else: - await ctx.send_help("pep") + await ctx.send_help(ctx.command) return # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 37f2d2b9d..e4fb173e0 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -30,7 +30,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): @with_role(*MODERATION_ROLES) async def bigbrother_group(self, ctx: Context) -> None: """Monitors users by relaying their messages to the Big Brother watch channel.""" - await ctx.send_help("bigbrother") + await ctx.send_help(ctx.command) @bigbrother_group.command(name='watched', aliases=('all', 'list')) @with_role(*MODERATION_ROLES) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index b8473963d..9a85c68c2 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -34,7 +34,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @with_role(*MODERATION_ROLES) async def nomination_group(self, ctx: Context) -> None: """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" - await ctx.send_help("talentpool") + await ctx.send_help(ctx.command) @nomination_group.command(name='watched', aliases=('all', 'list')) @with_role(*MODERATION_ROLES) @@ -173,7 +173,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @with_role(*MODERATION_ROLES) async def nomination_edit_group(self, ctx: Context) -> None: """Commands to edit nominations.""" - await ctx.send_help("talentpool edit") + await ctx.send_help(ctx.command) @nomination_edit_group.command(name='reason') @with_role(*MODERATION_ROLES) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 190d41d66..8490b02ca 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -208,9 +208,9 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): async def test_eval_command_call_help(self): """Test if the eval command call the help command if no code is provided.""" - ctx = MockContext() + ctx = MockContext(command="sentinel") await self.cog.eval_command.callback(self.cog, ctx=ctx, code='') - ctx.send_help.assert_called_once_with("eval") + ctx.send_help.assert_called_once_with("sentinel") async def test_send_eval(self): """Test the send_eval function.""" -- cgit v1.2.3 From 4f76259976491ff68247b0f66a6549b37bc090bd Mon Sep 17 00:00:00 2001 From: decorator-factory <42166884+decorator-factory@users.noreply.github.com> Date: Sun, 17 May 2020 14:16:45 +0300 Subject: Rename `string` to `greeting` --- bot/resources/tags/mutability.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/bot/resources/tags/mutability.md b/bot/resources/tags/mutability.md index 3447109ad..ef2f47403 100644 --- a/bot/resources/tags/mutability.md +++ b/bot/resources/tags/mutability.md @@ -4,11 +4,11 @@ Imagine that you want to make all letters in a string upper case. Conveniently, You might think that this would work: ```python ->>> string = "abcd" ->>> string.upper() -'ABCD' ->>> string -'abcd' +>>> greeting = "hello" +>>> greeting.upper() +'HELLO' +>>> greeting +'hello' ``` `string` didn't change. Why is that so? @@ -16,13 +16,13 @@ You might think that this would work: That's because strings in Python are _immutable_. You can't change them, you can only pass around existing strings or create new ones. ```python ->>> string = "abcd" ->>> string = string.upper() ->>> string -'ABCD' +>>> greeting = "hello" +>>> greeting = greeting.upper() +>>> greeting +'HELLO' ``` -`string.upper()` creates and returns a new string which is like the old one, but with all the letters turned to upper case. +`greeting.upper()` creates and returns a new string which is like the old one, but with all the letters turned to upper case. `int`, `float`, `complex`, `tuple`, `frozenset` are other examples of immutable data types in Python. -- cgit v1.2.3 From d390fb257011f9c5cdd7e6d35a1b194303aa9e5d Mon Sep 17 00:00:00 2001 From: decorator-factory <42166884+decorator-factory@users.noreply.github.com> Date: Sun, 17 May 2020 14:19:10 +0300 Subject: Fix incomplete variable renaming --- bot/resources/tags/mutability.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/mutability.md b/bot/resources/tags/mutability.md index ef2f47403..bde9b5e7e 100644 --- a/bot/resources/tags/mutability.md +++ b/bot/resources/tags/mutability.md @@ -11,7 +11,7 @@ You might think that this would work: 'hello' ``` -`string` didn't change. Why is that so? +`greeting` didn't change. Why is that so? That's because strings in Python are _immutable_. You can't change them, you can only pass around existing strings or create new ones. -- cgit v1.2.3 From bd4b439bb7f4abb6b22ad6e0d33bbe9203317475 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Tue, 19 May 2020 01:30:33 +0100 Subject: [bug] Adjustment to changes in #941, return message sent by webhook so publish can take place --- bot/cogs/reddit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index a300cfe0f..3b77538a0 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -218,7 +218,7 @@ class Reddit(Cog): for subreddit in RedditConfig.subreddits: top_posts = await self.get_top_posts(subreddit=subreddit, time="day") - message = await self.webhook.send(username=f"{subreddit} Top Daily Posts", embed=top_posts) + message = await self.webhook.send(username=f"{subreddit} Top Daily Posts", embed=top_posts, wait=True) if message.channel.is_news(): await message.publish() -- cgit v1.2.3 From 1d36ec7001c35d96ff24f7493d26997c02891e93 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Mon, 18 May 2020 21:17:53 -0400 Subject: Add Steam gift card scam to domain blacklist --- config-default.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config-default.yml b/config-default.yml index 83ea59016..c0b5b062f 100644 --- a/config-default.yml +++ b/config-default.yml @@ -316,6 +316,8 @@ filter: - poweredbydialup.online - poweredbysecurity.org - poweredbysecurity.online + - ssteam.site + - steamwalletgift.com word_watchlist: - goo+ks* -- cgit v1.2.3 From 75385da574dfdd622ab4b0c7d5771ebd3218542d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 19 May 2020 07:58:40 +0300 Subject: Stats: Fixed stat names Co-authored-by: Joseph Banks --- bot/cogs/snekbox.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 1d240d8d8..efff6d815 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -207,9 +207,9 @@ class Snekbox(Cog): # Collect stats of eval fails + successes if icon == ":x:": - self.bot.stats.incr("evals.fail") + self.bot.stats.incr("snekbox.python.fail") elif icon in (":warning:", ":white_check_mark:"): - self.bot.stats.incr("evals.success") + self.bot.stats.incr("snekbox.python.success") response = await ctx.send(msg) self.bot.loop.create_task( @@ -299,16 +299,16 @@ class Snekbox(Cog): return if Roles.helpers in (role.id for role in ctx.author.roles): - self.bot.stats.incr("evals.roles.helpers") + self.bot.stats.incr("snekbox_usages.roles.helpers") else: - self.bot.stats.incr("evals.roles.developers") + self.bot.stats.incr("snekbox_usages.roles.developers") if ctx.channel.category_id == Categories.help_in_use: - self.bot.stats.incr("evals.channels.help") + self.bot.stats.incr("snekbox_usages.channels.help") elif ctx.channel.id == Channels.bot_commands: - self.bot.stats.incr("evals.channels.bot_commands") + self.bot.stats.incr("snekbox_usages.channels.bot_commands") else: - self.bot.stats.incr("evals.channels.topical") + self.bot.stats.incr("snekbox_usages.channels.topical") log.info(f"Received code from {ctx.author} for evaluation:\n{code}") -- cgit v1.2.3 From 3081aad0cb360436c451b9a4515d494711adaf81 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 19 May 2020 07:59:21 +0300 Subject: Stats: Fix docstrings Co-authored-by: Joseph Banks --- bot/cogs/stats.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/stats.py b/bot/cogs/stats.py index acee1f5a9..9baf222e2 100644 --- a/bot/cogs/stats.py +++ b/bot/cogs/stats.py @@ -106,14 +106,14 @@ class Stats(Cog): @loop(hours=1) async def update_guild_boost(self) -> None: - """Update every hour guild boosts amount + level.""" + """Post the server boost level and tier every hour.""" await self.bot.wait_until_guild_available() g = self.bot.get_guild(Guild.id) self.bot.stats.gauge("boost.amount", g.premium_subscription_count) self.bot.stats.gauge("boost.tier", g.premium_tier) def cog_unload(self) -> None: - """Stop guild boost stat collecting task on Cog unload.""" + """Stop the boost statistic task on unload of the Cog.""" self.update_guild_boost.stop() -- cgit v1.2.3 From 6c5979b88961cb1df3d669e07ae108d12e698119 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 21 May 2020 11:47:39 +0300 Subject: Config: Added new `HelpChannels` config `deleted_idle_minutes` This show how much minutes should this wait before making channel dormant when no messages in channel (original message deleted). --- bot/constants.py | 1 + config-default.yml | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index fd280e9de..3003c9d36 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -541,6 +541,7 @@ class HelpChannels(metaclass=YAMLGetter): claim_minutes: int cmd_whitelist: List[int] idle_minutes: int + deleted_idle_minutes: int max_available: int max_total_channels: int name_prefix: str diff --git a/config-default.yml b/config-default.yml index 83ea59016..2e8a777ba 100644 --- a/config-default.yml +++ b/config-default.yml @@ -529,6 +529,10 @@ help_channels: # Allowed duration of inactivity before making a channel dormant idle_minutes: 30 + # Allowed duration of inactivity when question message deleted + # and no one other sent before message making channel dormant. + deleted_idle_minutes: 5 + # Maximum number of channels to put in the available category max_available: 2 -- cgit v1.2.3 From 0c84302f7e3475c13924fda33c52e98566114082 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 21 May 2020 12:27:25 +0300 Subject: Help: Implemented faster close when claimant delete msg no more messages - Created function `is_empty` that check is there any message in channel after bot own available message. - `on_message_delete` that reschedule task when message is on correct channel and is empty. - In `move_idle_channel` function, implemented choosing right cooldown, based on is channel empty or not. --- bot/cogs/help_channels.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 1bd1f9d68..4415ce550 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -461,7 +461,11 @@ class HelpChannels(Scheduler, commands.Cog): """ log.trace(f"Handling in-use channel #{channel} ({channel.id}).") - idle_seconds = constants.HelpChannels.idle_minutes * 60 + if not self.is_empty(channel): + idle_seconds = constants.HelpChannels.idle_minutes * 60 + else: + idle_seconds = constants.HelpChannels.deleted_idle_minutes * 60 + time_elapsed = await self.get_idle_time(channel) if time_elapsed is None or time_elapsed >= idle_seconds: @@ -713,6 +717,32 @@ class HelpChannels(Scheduler, commands.Cog): # be put in the queue. await self.move_to_available() + @commands.Cog.listener() + async def on_message_delete(self, msg: discord.Message) -> None: + """Reschedule dormant when help channel is empty.""" + if not self.is_in_category(msg.channel, constants.Categories.help_in_use) or not self.is_empty(msg.channel): + return + + log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.") + + # Cancel existing dormant task before scheduling new. + self.cancel_task(msg.channel.id) + + task = TaskData(constants.HelpChannels.deleted_idle_minutes * 60, self.move_idle_channel(msg.channel)) + self.schedule_task(msg.channel.id, task) + + async def is_empty(self, channel: discord.TextChannel) -> bool: + """Check is last message bot sent available message.""" + msg = await self.get_last_message(channel) + if not msg or not msg.author.bot or not msg.embeds: + return False + + embed = msg.embeds[0] + if embed.description == AVAILABLE_MSG: + return True + else: + return False + async def reset_send_permissions(self) -> None: """Reset send permissions in the Available category for claimants.""" log.trace("Resetting send permissions in the Available category.") -- cgit v1.2.3 From a46eff8d976cf65155f27ed75f49bd3e58155c84 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 22 May 2020 08:12:08 +0300 Subject: Help: Fix docstrings of `is_empty` and `on_message_delete` Co-authored-by: Mark --- bot/cogs/help_channels.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 4415ce550..ed1f7c55e 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -719,7 +719,11 @@ class HelpChannels(Scheduler, commands.Cog): @commands.Cog.listener() async def on_message_delete(self, msg: discord.Message) -> None: - """Reschedule dormant when help channel is empty.""" + """ + Reschedule an in-use channel to become dormant sooner if the channel is empty. + + The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`. + """ if not self.is_in_category(msg.channel, constants.Categories.help_in_use) or not self.is_empty(msg.channel): return @@ -732,7 +736,7 @@ class HelpChannels(Scheduler, commands.Cog): self.schedule_task(msg.channel.id, task) async def is_empty(self, channel: discord.TextChannel) -> bool: - """Check is last message bot sent available message.""" + """Return True if the most recent message in `channel` is the bot's `AVAILABLE_MSG`.""" msg = await self.get_last_message(channel) if not msg or not msg.author.bot or not msg.embeds: return False -- cgit v1.2.3 From 841ce9ba155d2aea3011500f5129d6b3dd309b99 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 22 May 2020 08:29:41 +0300 Subject: Help: Create `embed_description_match` - Created function `embed_description_match`. - Implemented this to `is_empty` - Implemented this to `is_dormant_message` --- bot/cogs/help_channels.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index ed1f7c55e..554fdc55e 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -440,11 +440,18 @@ class HelpChannels(Scheduler, commands.Cog): def is_dormant_message(self, message: t.Optional[discord.Message]) -> bool: """Return True if the contents of the `message` match `DORMANT_MSG`.""" - if not message or not message.embeds: + if not message: + return False + + return self.embed_description_match(message, DORMANT_MSG) + + def embed_description_match(self, message: discord.Message, text: str) -> bool: + """Return `True` if `message` embed description match with `text`.""" + if not message.embeds: return False embed = message.embeds[0] - return message.author == self.bot.user and embed.description.strip() == DORMANT_MSG.strip() + return message.author == self.bot.user and embed.description.strip() == text.strip() @staticmethod def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: @@ -722,7 +729,7 @@ class HelpChannels(Scheduler, commands.Cog): """ Reschedule an in-use channel to become dormant sooner if the channel is empty. - The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`. + The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`. """ if not self.is_in_category(msg.channel, constants.Categories.help_in_use) or not self.is_empty(msg.channel): return @@ -738,14 +745,10 @@ class HelpChannels(Scheduler, commands.Cog): async def is_empty(self, channel: discord.TextChannel) -> bool: """Return True if the most recent message in `channel` is the bot's `AVAILABLE_MSG`.""" msg = await self.get_last_message(channel) - if not msg or not msg.author.bot or not msg.embeds: + if not msg: return False - embed = msg.embeds[0] - if embed.description == AVAILABLE_MSG: - return True - else: - return False + return self.embed_description_match(msg, AVAILABLE_MSG) async def reset_send_permissions(self) -> None: """Reset send permissions in the Available category for claimants.""" -- cgit v1.2.3 From b3619949a17ba40a3b1f6364cf83464275717283 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 22 May 2020 08:35:03 +0300 Subject: Eval Stats: Replaced `elif` with `else` on icon check Co-authored-by: Mark --- bot/cogs/snekbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index efff6d815..e2e55e7ca 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -208,7 +208,7 @@ class Snekbox(Cog): # Collect stats of eval fails + successes if icon == ":x:": self.bot.stats.incr("snekbox.python.fail") - elif icon in (":warning:", ":white_check_mark:"): + else: self.bot.stats.incr("snekbox.python.success") response = await ctx.send(msg) -- cgit v1.2.3 From d3550ce1138e1b00e64ba355a09a08b480b077e6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 22 May 2020 11:11:43 -0700 Subject: HelpChannels: fix `is_empty` not being awaited --- bot/cogs/help_channels.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 554fdc55e..2aec22be4 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -468,7 +468,7 @@ class HelpChannels(Scheduler, commands.Cog): """ log.trace(f"Handling in-use channel #{channel} ({channel.id}).") - if not self.is_empty(channel): + if not await self.is_empty(channel): idle_seconds = constants.HelpChannels.idle_minutes * 60 else: idle_seconds = constants.HelpChannels.deleted_idle_minutes * 60 @@ -731,7 +731,10 @@ class HelpChannels(Scheduler, commands.Cog): The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`. """ - if not self.is_in_category(msg.channel, constants.Categories.help_in_use) or not self.is_empty(msg.channel): + if not self.is_in_category(msg.channel, constants.Categories.help_in_use): + return + + if not await self.is_empty(msg.channel): return log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.") -- cgit v1.2.3 From 8deeeca83c6c2c3de3b856ea8d6f94f8b5db3526 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 22 May 2020 11:16:21 -0700 Subject: HelpChannels: rename `embed_description_match` --- bot/cogs/help_channels.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 2aec22be4..b9b577256 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -443,15 +443,15 @@ class HelpChannels(Scheduler, commands.Cog): if not message: return False - return self.embed_description_match(message, DORMANT_MSG) + return self.match_bot_embed(message, DORMANT_MSG) - def embed_description_match(self, message: discord.Message, text: str) -> bool: - """Return `True` if `message` embed description match with `text`.""" + def match_bot_embed(self, message: discord.Message, description: str) -> bool: + """Return `True` if the bot's `message`'s embed description matches `description`.""" if not message.embeds: return False embed = message.embeds[0] - return message.author == self.bot.user and embed.description.strip() == text.strip() + return message.author == self.bot.user and embed.description.strip() == description.strip() @staticmethod def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: @@ -751,7 +751,7 @@ class HelpChannels(Scheduler, commands.Cog): if not msg: return False - return self.embed_description_match(msg, AVAILABLE_MSG) + return self.match_bot_embed(msg, AVAILABLE_MSG) async def reset_send_permissions(self) -> None: """Reset send permissions in the Available category for claimants.""" -- cgit v1.2.3 From e8266b8e1029f31dea3ad6ecbe36b7df56b8acdc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 22 May 2020 11:20:46 -0700 Subject: HelpChannels: move message None check inside `match_bot_embed` It was being done repeatedly outside the function so let's move it in to reduce redundancy. --- bot/cogs/help_channels.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index b9b577256..07acff34d 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -440,14 +440,11 @@ class HelpChannels(Scheduler, commands.Cog): def is_dormant_message(self, message: t.Optional[discord.Message]) -> bool: """Return True if the contents of the `message` match `DORMANT_MSG`.""" - if not message: - return False - return self.match_bot_embed(message, DORMANT_MSG) - def match_bot_embed(self, message: discord.Message, description: str) -> bool: + def match_bot_embed(self, message: t.Optional[discord.Message], description: str) -> bool: """Return `True` if the bot's `message`'s embed description matches `description`.""" - if not message.embeds: + if not message or not message.embeds: return False embed = message.embeds[0] @@ -748,9 +745,6 @@ class HelpChannels(Scheduler, commands.Cog): async def is_empty(self, channel: discord.TextChannel) -> bool: """Return True if the most recent message in `channel` is the bot's `AVAILABLE_MSG`.""" msg = await self.get_last_message(channel) - if not msg: - return False - return self.match_bot_embed(msg, AVAILABLE_MSG) async def reset_send_permissions(self) -> None: -- cgit v1.2.3 From 278ae309be27058920424c4049272bd5171bc158 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 22 May 2020 11:22:27 -0700 Subject: HelpChannels: remove `is_dormant_message` At this point, it's just a thin wrapper to call another function. It's redundant. --- bot/cogs/help_channels.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 07acff34d..f0e6746f0 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -438,10 +438,6 @@ class HelpChannels(Scheduler, commands.Cog): """Return True if `member` has the 'Help Cooldown' role.""" return any(constants.Roles.help_cooldown == role.id for role in member.roles) - def is_dormant_message(self, message: t.Optional[discord.Message]) -> bool: - """Return True if the contents of the `message` match `DORMANT_MSG`.""" - return self.match_bot_embed(message, DORMANT_MSG) - def match_bot_embed(self, message: t.Optional[discord.Message], description: str) -> bool: """Return `True` if the bot's `message`'s embed description matches `description`.""" if not message or not message.embeds: @@ -822,7 +818,7 @@ class HelpChannels(Scheduler, commands.Cog): embed = discord.Embed(description=AVAILABLE_MSG) msg = await self.get_last_message(channel) - if self.is_dormant_message(msg): + if self.match_bot_embed(msg, DORMANT_MSG): log.trace(f"Found dormant message {msg.id} in {channel_info}; editing it.") await msg.edit(embed=embed) else: -- cgit v1.2.3 From 185c9e84b5fde13ab21de614564eee94963d05b5 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Sun, 24 May 2020 22:46:48 +0100 Subject: Add discord.gift to URL blacklist, closes #958 --- config-default.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config-default.yml b/config-default.yml index 2e98186f1..9a28be700 100644 --- a/config-default.yml +++ b/config-default.yml @@ -318,6 +318,7 @@ filter: - poweredbysecurity.online - ssteam.site - steamwalletgift.com + - discord.gift word_watchlist: - goo+ks* -- cgit v1.2.3