aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar RohanJnr <[email protected]>2020-03-12 22:34:00 +0530
committerGravatar RohanJnr <[email protected]>2020-03-12 22:34:00 +0530
commitd56639c721243713fc707502fe057d7eae045e21 (patch)
treeb6c1a4ff56ae3c2596502cde5b2528b657e267f7
parentconvert get_tags() method to staticmethod (diff)
parentMerge 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.yaml1
-rw-r--r--azure-pipelines.yml2
-rw-r--r--bot/cogs/antimalware.py4
-rw-r--r--bot/cogs/information.py15
-rw-r--r--bot/cogs/moderation/modlog.py2
-rw-r--r--bot/cogs/tags.py71
-rw-r--r--bot/cogs/token_remover.py13
-rw-r--r--bot/cogs/utils.py109
-rw-r--r--tests/bot/cogs/test_information.py5
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."""