diff options
author | 2020-03-12 22:34:00 +0530 | |
---|---|---|
committer | 2020-03-12 22:34:00 +0530 | |
commit | d56639c721243713fc707502fe057d7eae045e21 (patch) | |
tree | b6c1a4ff56ae3c2596502cde5b2528b657e267f7 | |
parent | convert get_tags() method to staticmethod (diff) | |
parent | Merge branch 'master' into tags_overhaul (diff) |
Merge branch 'tags_overhaul' of https://github.com/RohanJnr/bot into tags_overhaul
-rw-r--r-- | .pre-commit-config.yaml | 1 | ||||
-rw-r--r-- | azure-pipelines.yml | 2 | ||||
-rw-r--r-- | bot/cogs/antimalware.py | 4 | ||||
-rw-r--r-- | bot/cogs/information.py | 15 | ||||
-rw-r--r-- | bot/cogs/moderation/modlog.py | 2 | ||||
-rw-r--r-- | bot/cogs/tags.py | 71 | ||||
-rw-r--r-- | bot/cogs/token_remover.py | 13 | ||||
-rw-r--r-- | bot/cogs/utils.py | 109 | ||||
-rw-r--r-- | tests/bot/cogs/test_information.py | 5 |
9 files changed, 200 insertions, 22 deletions
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f369fb7d1..876d32b15 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,3 @@ -exclude: ^\.cache/|\.venv/|\.git/|htmlcov/|logs/ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v2.5.0 diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 280f11a36..16d1b7a2a 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -47,7 +47,7 @@ jobs: pre-commit | "$(PythonVersion.pythonLocation)" path: $(PRE_COMMIT_HOME) - - script: pre-commit run --all-files --show-diff-on-failure + - script: pre-commit run --all-files displayName: 'Run pre-commit hooks' - script: BOT_API_KEY=foo BOT_SENTRY_DSN=blah BOT_TOKEN=bar WOLFRAM_API_KEY=baz REDDIT_CLIENT_ID=spam REDDIT_SECRET=ham coverage run -m xmlrunner diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 373619895..79bf486a4 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -29,8 +29,9 @@ class AntiMalware(Cog): return embed = Embed() - file_extensions = {splitext(message.filename.lower())[1] for message in message.attachments} + file_extensions = {splitext(attachment.filename.lower())[1] for attachment in message.attachments} extensions_blocked = file_extensions - set(AntiMalwareConfig.whitelist) + blocked_extensions_str = ', '.join(extensions_blocked) if ".py" in extensions_blocked: # Short-circuit on *.py files to provide a pastebin link embed.description = ( @@ -38,7 +39,6 @@ class AntiMalware(Cog): f"please use a code-pasting service such as {URLs.site_schema}{URLs.site_paste}" ) elif extensions_blocked: - blocked_extensions_str = ', '.join(extensions_blocked) whitelisted_types = ', '.join(AntiMalwareConfig.whitelist) meta_channel = self.bot.get_channel(Channels.meta) embed.description = ( diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 49beca15b..7921a4932 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -13,6 +13,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot from bot.decorators import InChannelCheckFailure, in_channel, with_role +from bot.pagination import LinePaginator from bot.utils.checks import cooldown_with_role_bypass, with_role_check from bot.utils.time import time_since @@ -32,20 +33,18 @@ class Information(Cog): # Sort the roles alphabetically and remove the @everyone role roles = sorted(ctx.guild.roles[1:], key=lambda role: role.name) - # Build a string - role_string = "" + # Build a list + role_list = [] for role in roles: - role_string += f"`{role.id}` - {role.mention}\n" + role_list.append(f"`{role.id}` - {role.mention}") # Build an embed embed = Embed( - title="Role information", - colour=Colour.blurple(), - description=role_string + title=f"Role information (Total {len(roles)} role{'s' * (len(role_list) > 1)})", + colour=Colour.blurple() ) - embed.set_footer(text=f"Total roles: {len(roles)}") - await ctx.send(embed=embed) + await LinePaginator.paginate(role_list, ctx, embed, empty=False) @with_role(*constants.MODERATION_ROLES) @command(name="role") diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 59ae6b587..81d95298d 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -67,7 +67,7 @@ class ModLog(Cog, name="ModLog"): 'embeds': [embed.to_dict() for embed in message.embeds], 'attachments': attachment, } - for message, attachment in zip_longest(messages, attachments) + for message, attachment in zip_longest(messages, attachments, fillvalue=[]) ] } ) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 48f000143..fecaf926d 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -2,7 +2,7 @@ import logging import re import time from pathlib import Path -from typing import Dict, List, Optional +from typing import Callable, Dict, Iterable, List, Optional from discord import Colour, Embed from discord.ext.commands import Cog, Context, group @@ -91,11 +91,80 @@ class Tags(Cog): return self._get_suggestions(tag_name) return found + async def _get_tags_via_content(self, check: Callable[[Iterable], bool], keywords: str) -> list: + """ + Search for tags via contents. + + `predicate` will be the built-in any, all, or a custom callable. Must return a bool. + """ + await self._get_tags() + + keywords_processed: List[str] = [] + for keyword in keywords.split(','): + keyword_sanitized = keyword.strip().casefold() + if not keyword_sanitized: + # this happens when there are leading / trailing / consecutive comma. + continue + keywords_processed.append(keyword_sanitized) + + if not keywords_processed: + # after sanitizing, we can end up with an empty list, for example when keywords is ',' + # in that case, we simply want to search for such keywords directly instead. + keywords_processed = [keywords] + + matching_tags = [] + for tag in self._cache.values(): + if check(query in tag['embed']['description'].casefold() for query in keywords_processed): + matching_tags.append(tag) + + return matching_tags + + async def _send_matching_tags(self, ctx: Context, keywords: str, matching_tags: list) -> None: + """Send the result of matching tags to user.""" + if not matching_tags: + pass + elif len(matching_tags) == 1: + await ctx.send(embed=Embed().from_dict(matching_tags[0]['embed'])) + else: + is_plural = keywords.strip().count(' ') > 0 or keywords.strip().count(',') > 0 + embed = Embed( + title=f"Here are the tags containing the given keyword{'s' * is_plural}:", + description='\n'.join(tag['title'] for tag in matching_tags[:10]) + ) + await LinePaginator.paginate( + sorted(f"**ยป** {tag['title']}" for tag in matching_tags), + ctx, + embed, + footer_text="To show a tag, type !tags <tagname>.", + empty=False, + max_lines=15 + ) + @group(name='tags', aliases=('tag', 't'), invoke_without_command=True) async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: """Show all known tags, a single tag, or run a subcommand.""" await ctx.invoke(self.get_command, tag_name=tag_name) + @tags_group.group(name='search', invoke_without_command=True) + async def search_tag_content(self, ctx: Context, *, keywords: str) -> None: + """ + Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma. + + Only search for tags that has ALL the keywords. + """ + matching_tags = await self._get_tags_via_content(all, keywords) + await self._send_matching_tags(ctx, keywords, matching_tags) + + @search_tag_content.command(name='any') + async def search_tag_content_any_keyword(self, ctx: Context, *, keywords: Optional[str] = None) -> None: + """ + Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma. + + Search for tags that has ANY of the keywords. + """ + matching_tags = await self._get_tags_via_content(any, keywords or 'any') + await self._send_matching_tags(ctx, keywords, matching_tags) + @tags_group.command(name='get', aliases=('show', 'g')) async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: """Get a specified tag, or a list of all tags if no tag is specified.""" diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 82c01ae96..547ba8da0 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -96,12 +96,19 @@ class TokenRemover(Cog): if msg.author.bot: return False - maybe_match = TOKEN_RE.search(msg.content) - if maybe_match is None: + # Use findall rather than search to guard against method calls prematurely returning the + # token check (e.g. `message.channel.send` also matches our token pattern) + maybe_matches = TOKEN_RE.findall(msg.content) + if not maybe_matches: return False + return any(cls.is_maybe_token(substr) for substr in maybe_matches) + + @classmethod + def is_maybe_token(cls, test_str: str) -> bool: + """Check the provided string to see if it is a seemingly valid token.""" try: - user_id, creation_timestamp, hmac = maybe_match.group(0).split('.') + user_id, creation_timestamp, hmac = test_str.split('.') except ValueError: return False diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 94b9d6b5a..024141d62 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -1,14 +1,15 @@ +import difflib import logging import re import unicodedata from asyncio import TimeoutError, sleep from email.parser import HeaderParser from io import StringIO -from typing import Tuple +from typing import Tuple, Union from dateutil import relativedelta from discord import Colour, Embed, Message, Role -from discord.ext.commands import Cog, Context, command +from discord.ext.commands import BadArgument, Cog, Context, command from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES, Mention, STAFF_ROLES @@ -17,6 +18,28 @@ from bot.utils.time import humanize_delta log = logging.getLogger(__name__) +ZEN_OF_PYTHON = """\ +Beautiful is better than ugly. +Explicit is better than implicit. +Simple is better than complex. +Complex is better than complicated. +Flat is better than nested. +Sparse is better than dense. +Readability counts. +Special cases aren't special enough to break the rules. +Although practicality beats purity. +Errors should never pass silently. +Unless explicitly silenced. +In the face of ambiguity, refuse the temptation to guess. +There should be one-- and preferably only one --obvious way to do it. +Although that way may not be obvious at first unless you're Dutch. +Now is better than never. +Although never is often better than *right* now. +If the implementation is hard to explain, it's a bad idea. +If the implementation is easy to explain, it may be a good idea. +Namespaces are one honking great idea -- let's do more of those! +""" + class Utils(Cog): """A selection of utilities which don't have a clear category.""" @@ -173,6 +196,88 @@ class Utils(Cog): f"as I detected unauthorised use by {msg.author} (ID: {msg.author.id})." ) + @command() + async def zen(self, ctx: Context, *, search_value: Union[int, str, None] = None) -> None: + """ + Show the Zen of Python. + + Without any arguments, the full Zen will be produced. + If an integer is provided, the line with that index will be produced. + If a string is provided, the line which matches best will be produced. + """ + embed = Embed( + colour=Colour.blurple(), + title="The Zen of Python", + description=ZEN_OF_PYTHON + ) + + if search_value is None: + embed.title += ", by Tim Peters" + await ctx.send(embed=embed) + return + + zen_lines = ZEN_OF_PYTHON.splitlines() + + # handle if it's an index int + if isinstance(search_value, int): + upper_bound = len(zen_lines) - 1 + lower_bound = -1 * upper_bound + if not (lower_bound <= search_value <= upper_bound): + raise BadArgument(f"Please provide an index between {lower_bound} and {upper_bound}.") + + embed.title += f" (line {search_value % len(zen_lines)}):" + embed.description = zen_lines[search_value] + await ctx.send(embed=embed) + return + + # handle if it's a search string + matcher = difflib.SequenceMatcher(None, search_value.lower()) + + best_match = "" + match_index = 0 + best_ratio = 0 + + for index, line in enumerate(zen_lines): + matcher.set_seq2(line.lower()) + + # the match ratio needs to be adjusted because, naturally, + # longer lines will have worse ratios than shorter lines when + # fuzzy searching for keywords. this seems to work okay. + adjusted_ratio = (len(line) - 5) ** 0.5 * matcher.ratio() + + if adjusted_ratio > best_ratio: + best_ratio = adjusted_ratio + best_match = line + match_index = index + + if not best_match: + raise BadArgument("I didn't get a match! Please try again with a different search term.") + + embed.title += f" (line {match_index}):" + embed.description = best_match + await ctx.send(embed=embed) + + @command(aliases=("poll",)) + @with_role(*MODERATION_ROLES) + async def vote(self, ctx: Context, title: str, *options: str) -> None: + """ + Build a quick voting poll with matching reactions with the provided options. + + A maximum of 20 options can be provided, as Discord supports a max of 20 + reactions on a single message. + """ + if len(options) < 2: + raise BadArgument("Please provide at least 2 options.") + if len(options) > 20: + raise BadArgument("I can only handle 20 options!") + + codepoint_start = 127462 # represents "regional_indicator_a" unicode value + options = {chr(i): f"{chr(i)} - {v}" for i, v in enumerate(options, start=codepoint_start)} + embed = Embed(title=title, description="\n".join(options.values())) + message = await ctx.send(embed=embed) + for reaction in options: + await message.add_reaction(reaction) + def setup(bot: Bot) -> None: """Load the Utils cog.""" diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 5693d2946..3c26374f5 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -45,10 +45,9 @@ class InformationCogTests(unittest.TestCase): _, kwargs = self.ctx.send.call_args embed = kwargs.pop('embed') - self.assertEqual(embed.title, "Role information") + self.assertEqual(embed.title, "Role information (Total 1 role)") self.assertEqual(embed.colour, discord.Colour.blurple()) - self.assertEqual(embed.description, f"`{self.moderator_role.id}` - {self.moderator_role.mention}\n") - self.assertEqual(embed.footer.text, "Total roles: 1") + self.assertEqual(embed.description, f"\n`{self.moderator_role.id}` - {self.moderator_role.mention}\n") def test_role_info_command(self): """Tests the `role info` command.""" |