From 83e49c25d0cdd473ddf2d5feb4aa85d041ed596b Mon Sep 17 00:00:00 2001 From: Jeremiah Boby Date: Sun, 13 Oct 2019 18:03:07 +0100 Subject: Check partially hidden words against the wordlist --- bot/cogs/filtering.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 265ae5160..875276d8a 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -26,6 +26,7 @@ INVITE_RE = re.compile( flags=re.IGNORECASE ) +SPOILER_RE = re.compile(r"(\|\|.+?\|\|)") URL_RE = re.compile(r"(https?://[^\s]+)", flags=re.IGNORECASE) ZALGO_RE = re.compile(r"[\u0300-\u036F\u0489]") @@ -237,7 +238,7 @@ class Filtering(Cog): Only matches words with boundaries before and after the expression. """ for regex_pattern in WORD_WATCHLIST_PATTERNS: - if regex_pattern.search(text): + if regex_pattern.search(text + SPOILER_RE.sub('', text)): return True return False -- cgit v1.2.3 From e731db98569d55051b944278221449a206992850 Mon Sep 17 00:00:00 2001 From: Jeremiah Boby Date: Mon, 21 Oct 2019 12:58:26 +0100 Subject: Update spoiler regex to support multi-line spoilers --- bot/cogs/filtering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 875276d8a..fd90ff836 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -26,7 +26,7 @@ INVITE_RE = re.compile( flags=re.IGNORECASE ) -SPOILER_RE = re.compile(r"(\|\|.+?\|\|)") +SPOILER_RE = re.compile(r"(\|\|.+?\|\|)", re.DOTALL) URL_RE = re.compile(r"(https?://[^\s]+)", flags=re.IGNORECASE) ZALGO_RE = re.compile(r"[\u0300-\u036F\u0489]") -- cgit v1.2.3 From cb951a920fd77eca35b355ca8835781e63250d78 Mon Sep 17 00:00:00 2001 From: Kingsley McDonald Date: Fri, 1 Nov 2019 23:54:05 +0000 Subject: implement !zen command. --- bot/cogs/utils.py | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 96 insertions(+), 2 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 793fe4c1a..db8e77062 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -1,21 +1,45 @@ +import difflib import logging +import random 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 Optional, Tuple from dateutil import relativedelta from discord import Colour, Embed, Message, Role from discord.ext.commands import Bot, Cog, Context, command -from bot.constants import Channels, MODERATION_ROLES, Mention, STAFF_ROLES +from bot.constants import Channels, MODERATION_ROLES, Mention, NEGATIVE_REPLIES, STAFF_ROLES from bot.decorators import in_channel, with_role 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.""" @@ -174,6 +198,76 @@ 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: Optional[str] = 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. + """ + if search_value is None: + embed = Embed( + colour=Colour.blurple(), + title="The Zen of Python, by Tim Peters", + description=ZEN_OF_PYTHON + ) + + return await ctx.send(embed=embed) + + zen_lines = ZEN_OF_PYTHON.splitlines() + + # check if it's an integer. could be negative. why not. + if search_value.lstrip("-").isdigit(): + index = int(search_value) + + try: + line = zen_lines[index] + except IndexError: + embed = Embed( + colour=Colour.red(), + title=random.choice(NEGATIVE_REPLIES), + description="Please provide a valid index." + ) + + else: + embed = Embed( + colour=Colour.blurple(), + title=f"The Zen of Python (line {index % len(zen_lines)}):", + description=line + ) + + return await ctx.send(embed=embed) + + # at this point, we must be dealing with a string search. + 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 + + embed = Embed( + colour=Colour.blurple(), + title=f"The Zen of Python (line {match_index}):", + description=best_match + ) + + return await ctx.send(embed=embed) + def setup(bot: Bot) -> None: """Utils cog load.""" -- cgit v1.2.3 From abee8a8c51cbea40b2265cec245071ab9a5297a1 Mon Sep 17 00:00:00 2001 From: Kingsley McDonald Date: Sat, 2 Nov 2019 12:15:22 +0000 Subject: apply kosa's requested changes. - return None from the command's coroutine as hinted, rather than a discord.Message object. - only check for one negative sign on !zen index searches (rather than any amount) so that `int(...)` does not fail. - provide a range of valid indices when a user requests a !zen index out of range. --- bot/cogs/utils.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index db8e77062..7dd5e2e56 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -214,12 +214,14 @@ class Utils(Cog): description=ZEN_OF_PYTHON ) - return await ctx.send(embed=embed) + await ctx.send(embed=embed) + return zen_lines = ZEN_OF_PYTHON.splitlines() # check if it's an integer. could be negative. why not. - if search_value.lstrip("-").isdigit(): + is_negative_integer = search_value[0] == "-" and search_value[1:].isdigit() + if search_value.isdigit() or is_negative_integer: index = int(search_value) try: @@ -228,9 +230,8 @@ class Utils(Cog): embed = Embed( colour=Colour.red(), title=random.choice(NEGATIVE_REPLIES), - description="Please provide a valid index." + description=f"Please provide an index between {-len(zen_lines)} and {len(zen_lines) - 1}." ) - else: embed = Embed( colour=Colour.blurple(), @@ -238,7 +239,8 @@ class Utils(Cog): description=line ) - return await ctx.send(embed=embed) + await ctx.send(embed=embed) + return # at this point, we must be dealing with a string search. matcher = difflib.SequenceMatcher(None, search_value.lower()) @@ -266,7 +268,7 @@ class Utils(Cog): description=best_match ) - return await ctx.send(embed=embed) + await ctx.send(embed=embed) def setup(bot: Bot) -> None: -- cgit v1.2.3 From def24f55c81865e1a7a8d93e0de7562a7abb556a Mon Sep 17 00:00:00 2001 From: Jeremiah Boby Date: Thu, 28 Nov 2019 10:28:52 +0000 Subject: Expand spoilers to match multiple interpretations --- bot/cogs/filtering.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index fd90ff836..f1651b4d0 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -38,6 +38,14 @@ TOKEN_WATCHLIST_PATTERNS = [ ] +def expand_spoilers(text: str) -> str: + """Return a string containing all interpretations of a spoilered message.""" + split_text = SPOILER_RE.split(text) + return ''.join( + split_text[0::2] + split_text[1::2] + split_text + ) + + class Filtering(Cog): """Filtering out invites, blacklisting domains, and warning us of certain regular expressions.""" @@ -237,8 +245,10 @@ class Filtering(Cog): Only matches words with boundaries before and after the expression. """ + if SPOILER_RE.search(text): + text = expand_spoilers(text) for regex_pattern in WORD_WATCHLIST_PATTERNS: - if regex_pattern.search(text + SPOILER_RE.sub('', text)): + if regex_pattern.search(text): return True return False -- cgit v1.2.3 From edffd9b59dcf275848076291ea22aae5e71326dc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 13 Feb 2020 09:03:44 -0800 Subject: API: accept additional session kwargs for recreate() These kwargs are merged with the kwargs given when the APIClient was created. This is useful for facilitating changing the session's connector with a new instance when the session needs to be recreated. * Rename _session_args attribute to _default_session_kwargs --- bot/api.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/bot/api.py b/bot/api.py index 56db99828..c168a869d 100644 --- a/bot/api.py +++ b/bot/api.py @@ -47,7 +47,7 @@ class APIClient: self._ready = asyncio.Event(loop=loop) self._creation_task = None - self._session_args = kwargs + self._default_session_kwargs = kwargs self.recreate() @@ -55,9 +55,13 @@ class APIClient: def _url_for(endpoint: str) -> str: return f"{URLs.site_schema}{URLs.site_api}/{quote_url(endpoint)}" - async def _create_session(self) -> None: - """Create the aiohttp session and set the ready event.""" - self.session = aiohttp.ClientSession(**self._session_args) + async def _create_session(self, **session_kwargs) -> None: + """ + Create the aiohttp session with `session_kwargs` and set the ready event. + + `session_kwargs` is merged with `_default_session_kwargs` and overwrites its values. + """ + self.session = aiohttp.ClientSession(**{**self._default_session_kwargs, **session_kwargs}) self._ready.set() async def close(self) -> None: @@ -68,12 +72,17 @@ class APIClient: await self.session.close() self._ready.clear() - def recreate(self) -> None: - """Schedule the aiohttp session to be created if it's been closed.""" + def recreate(self, **session_kwargs) -> None: + """ + Schedule the aiohttp session to be created with `session_kwargs` if it's been closed. + + `session_kwargs` is merged with the kwargs given when the `APIClient` was created and + overwrites those default kwargs. + """ if self.session is None or self.session.closed: # Don't schedule a task if one is already in progress. if self._creation_task is None or self._creation_task.done(): - self._creation_task = self.loop.create_task(self._create_session()) + self._creation_task = self.loop.create_task(self._create_session(**session_kwargs)) async def maybe_raise_for_status(self, response: aiohttp.ClientResponse, should_raise: bool) -> None: """Raise ResponseCodeError for non-OK response if an exception should be raised.""" -- cgit v1.2.3 From b19e6aaabdbcfefc5d22d1f72e670421fe8a8d97 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 13 Feb 2020 10:18:09 -0800 Subject: Bot: avoid DeprecationWarning for aiohttp.AsyncResolver (fix #748) AsyncResolver has to be created inside a coroutine so it's moved inside start(). Consequently, the APIClient session is also recreated inside start() now. When using clear(), the default connector is used for the session it recreates because clear() is not a coroutine. This should only affect requests made to the Discord API via the Client when not using it to run a bot; starting the bot will re-create the session with the custom connector. * Close connector and resolver when bot closes --- bot/bot.py | 45 ++++++++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 8f808272f..95fbae17f 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -14,18 +14,13 @@ class Bot(commands.Bot): """A subclass of `discord.ext.commands.Bot` with an aiohttp session and an API client.""" def __init__(self, *args, **kwargs): - # Use asyncio for DNS resolution instead of threads so threads aren't spammed. - # Use AF_INET as its socket family to prevent HTTPS related problems both locally - # and in production. - self.connector = aiohttp.TCPConnector( - resolver=aiohttp.AsyncResolver(), - family=socket.AF_INET, - ) - - super().__init__(*args, connector=self.connector, **kwargs) + super().__init__(*args, **kwargs) self.http_session: Optional[aiohttp.ClientSession] = None - self.api_client = api.APIClient(loop=self.loop, connector=self.connector) + self.api_client = api.APIClient(loop=self.loop) + + self._connector = None + self._resolver = None log.addHandler(api.APILoggingHandler(self.api_client)) @@ -35,19 +30,39 @@ class Bot(commands.Bot): log.info(f"Cog loaded: {cog.qualified_name}") def clear(self) -> None: - """Clears the internal state of the bot and resets the API client.""" + """Clears the internal state of the bot and sets the HTTPClient connector to None.""" + self.http.connector = None # Use the default connector. super().clear() - self.api_client.recreate() async def close(self) -> None: - """Close the aiohttp session after closing the Discord connection.""" + """Close the Discord connection and the aiohttp session, connector, and resolver.""" await super().close() await self.http_session.close() await self.api_client.close() + if self._connector: + await self._connector.close() + + if self._resolver: + await self._resolver.close() + async def start(self, *args, **kwargs) -> None: - """Open an aiohttp session before logging in and connecting to Discord.""" - self.http_session = aiohttp.ClientSession(connector=self.connector) + """Set up aiohttp sessions before logging in and connecting to Discord.""" + # Use asyncio for DNS resolution instead of threads so threads aren't spammed. + # Use AF_INET as its socket family to prevent HTTPS related problems both locally + # and in production. + self._resolver = aiohttp.AsyncResolver() + self._connector = aiohttp.TCPConnector( + resolver=self._resolver, + family=socket.AF_INET, + ) + + # Client.login() will call HTTPClient.static_login() which will create a session using + # this connector attribute. + self.http.connector = self._connector + + self.http_session = aiohttp.ClientSession(connector=self._connector) + self.api_client.recreate(connector=self._connector) await super().start(*args, **kwargs) -- cgit v1.2.3 From 253073ad059fc3a8eac890b4f3fe006454aae4b0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 13 Feb 2020 10:20:45 -0800 Subject: Bot: add warning for when connector is a specified kwarg --- bot/bot.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bot/bot.py b/bot/bot.py index 95fbae17f..762d316bf 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -1,5 +1,6 @@ import logging import socket +import warnings from typing import Optional import aiohttp @@ -14,6 +15,11 @@ class Bot(commands.Bot): """A subclass of `discord.ext.commands.Bot` with an aiohttp session and an API client.""" def __init__(self, *args, **kwargs): + if "connector" in kwargs: + warnings.warn( + "If the bot is started, the connector will be overwritten with an internal one" + ) + super().__init__(*args, **kwargs) self.http_session: Optional[aiohttp.ClientSession] = None -- cgit v1.2.3 From 9b6c9e8692313bd5ed70ce00c31e5b24e25635b1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 14 Feb 2020 12:23:59 -0800 Subject: Bot: fix error trying to close a None session --- bot/bot.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/bot.py b/bot/bot.py index 762d316bf..67a15faba 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -44,9 +44,11 @@ class Bot(commands.Bot): """Close the Discord connection and the aiohttp session, connector, and resolver.""" await super().close() - await self.http_session.close() await self.api_client.close() + if self.http_session: + await self.http_session.close() + if self._connector: await self._connector.close() -- cgit v1.2.3 From a21f4e63680e55149c33ee0bdde938281a8eb020 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Feb 2020 11:05:42 -0800 Subject: Bot: override login() instead of start() The client can be used without running a bot so it makes more sense for the connector to be created when logging in, which is done in both cases, rather than in start(), which is only used when running a bot. --- bot/bot.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 67a15faba..1d187f031 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -17,7 +17,8 @@ class Bot(commands.Bot): def __init__(self, *args, **kwargs): if "connector" in kwargs: warnings.warn( - "If the bot is started, the connector will be overwritten with an internal one" + "If login() is called (or the bot is started), the connector will be overwritten " + "with an internal one" ) super().__init__(*args, **kwargs) @@ -55,8 +56,8 @@ class Bot(commands.Bot): if self._resolver: await self._resolver.close() - async def start(self, *args, **kwargs) -> None: - """Set up aiohttp sessions before logging in and connecting to Discord.""" + async def login(self, *args, **kwargs) -> None: + """Re-create the connector and set up sessions before logging into Discord.""" # Use asyncio for DNS resolution instead of threads so threads aren't spammed. # Use AF_INET as its socket family to prevent HTTPS related problems both locally # and in production. @@ -73,4 +74,4 @@ class Bot(commands.Bot): self.http_session = aiohttp.ClientSession(connector=self._connector) self.api_client.recreate(connector=self._connector) - await super().start(*args, **kwargs) + await super().login(*args, **kwargs) -- cgit v1.2.3 From 5cc4e360aee28832ace207d8df2fb17b487fbfe7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Feb 2020 11:21:09 -0800 Subject: Bot: move connector/session recreation to a separate function The function itself doesn't need to be a coroutine. It just has to be called in a coroutine (or, more indirectly, in an async context?). --- bot/bot.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 1d187f031..e1b1d81dc 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -58,6 +58,11 @@ class Bot(commands.Bot): async def login(self, *args, **kwargs) -> None: """Re-create the connector and set up sessions before logging into Discord.""" + self._recreate() + await super().login(*args, **kwargs) + + def _recreate(self) -> None: + """Re-create the connector, aiohttp session, and the APIClient.""" # Use asyncio for DNS resolution instead of threads so threads aren't spammed. # Use AF_INET as its socket family to prevent HTTPS related problems both locally # and in production. @@ -73,5 +78,3 @@ class Bot(commands.Bot): self.http_session = aiohttp.ClientSession(connector=self._connector) self.api_client.recreate(connector=self._connector) - - await super().login(*args, **kwargs) -- cgit v1.2.3 From 6b689a15be69120a775789892f155c736926ef07 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Feb 2020 11:22:29 -0800 Subject: Bot: call _recreate() in clear() Because discord.py recreates the HTTPClient session, may as well follow suite and recreate our own stuff here too. --- bot/bot.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index e1b1d81dc..9f48c980c 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -37,8 +37,14 @@ class Bot(commands.Bot): log.info(f"Cog loaded: {cog.qualified_name}") def clear(self) -> None: - """Clears the internal state of the bot and sets the HTTPClient connector to None.""" - self.http.connector = None # Use the default connector. + """ + Clears the internal state of the bot and recreates the connector and sessions. + + Will cause a DeprecationWarning if called outside a coroutine. + """ + # Because discord.py recreates the HTTPClient session, may as well follow suite and recreate + # our own stuff here too. + self._recreate() super().clear() async def close(self) -> None: -- cgit v1.2.3 From a417c318e6a0e57fa53b9b68572a524e0aa0f729 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Feb 2020 11:38:27 -0800 Subject: Bot: warn when connector/session not closed when recreating aiohttp does warn too, but these warnings will provide more immediate feedback. --- bot/bot.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/bot/bot.py b/bot/bot.py index 9f48c980c..0287ec925 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -72,7 +72,18 @@ class Bot(commands.Bot): # Use asyncio for DNS resolution instead of threads so threads aren't spammed. # Use AF_INET as its socket family to prevent HTTPS related problems both locally # and in production. + + # Doesn't seem to have any state with regards to being closed, so no need to worry? self._resolver = aiohttp.AsyncResolver() + + # Does have a closed state. Its __del__ will warn about this, but let's do it immediately. + if self._connector and not self._connector._closed: + warnings.warn( + "The previous connector was not closed; it will remain open and be overwritten", + ResourceWarning, + stacklevel=2 + ) + self._connector = aiohttp.TCPConnector( resolver=self._resolver, family=socket.AF_INET, @@ -82,5 +93,12 @@ class Bot(commands.Bot): # this connector attribute. self.http.connector = self._connector + if self.http_session and not self.http_session.closed: + warnings.warn( + "The previous ClientSession was not closed; it will remain open and be overwritten", + ResourceWarning, + stacklevel=2 + ) + self.http_session = aiohttp.ClientSession(connector=self._connector) self.api_client.recreate(connector=self._connector) -- cgit v1.2.3 From f4e569b56d52f4e29024be4fdbae796e4d85aea6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Feb 2020 11:51:29 -0800 Subject: Bot: send not-closed warnings as log messages "Real" warnings weren't showing up for some reason. --- bot/bot.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 0287ec925..088b94a1f 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -70,20 +70,17 @@ class Bot(commands.Bot): def _recreate(self) -> None: """Re-create the connector, aiohttp session, and the APIClient.""" # Use asyncio for DNS resolution instead of threads so threads aren't spammed. - # Use AF_INET as its socket family to prevent HTTPS related problems both locally - # and in production. - # Doesn't seem to have any state with regards to being closed, so no need to worry? self._resolver = aiohttp.AsyncResolver() - # Does have a closed state. Its __del__ will warn about this, but let's do it immediately. + # Its __del__ does send a warning but it doesn't always show up for some reason. if self._connector and not self._connector._closed: - warnings.warn( - "The previous connector was not closed; it will remain open and be overwritten", - ResourceWarning, - stacklevel=2 + log.warning( + "The previous connector was not closed; it will remain open and be overwritten" ) + # Use AF_INET as its socket family to prevent HTTPS related problems both locally + # and in production. self._connector = aiohttp.TCPConnector( resolver=self._resolver, family=socket.AF_INET, @@ -93,11 +90,10 @@ class Bot(commands.Bot): # this connector attribute. self.http.connector = self._connector + # Its __del__ does send a warning but it doesn't always show up for some reason. if self.http_session and not self.http_session.closed: - warnings.warn( - "The previous ClientSession was not closed; it will remain open and be overwritten", - ResourceWarning, - stacklevel=2 + log.warning( + "The previous session was not closed; it will remain open and be overwritten" ) self.http_session = aiohttp.ClientSession(connector=self._connector) -- cgit v1.2.3 From fd313d2b1dac8b627d3d731e0898fbdff6642616 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Feb 2020 12:47:42 -0800 Subject: API: add argument to force recreation of the session --- bot/api.py | 12 +++++++++--- bot/bot.py | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/bot/api.py b/bot/api.py index c168a869d..37c1497fc 100644 --- a/bot/api.py +++ b/bot/api.py @@ -72,16 +72,22 @@ class APIClient: await self.session.close() self._ready.clear() - def recreate(self, **session_kwargs) -> None: + def recreate(self, force: bool = False, **session_kwargs) -> None: """ Schedule the aiohttp session to be created with `session_kwargs` if it's been closed. + If `force` is True, the session will be recreated even if an open one exists. If a task to + create the session is pending, it will be cancelled. + `session_kwargs` is merged with the kwargs given when the `APIClient` was created and overwrites those default kwargs. """ - if self.session is None or self.session.closed: + if force or self.session is None or self.session.closed: + if force and self._creation_task: + self._creation_task.cancel() + # Don't schedule a task if one is already in progress. - if self._creation_task is None or self._creation_task.done(): + if force or self._creation_task is None or self._creation_task.done(): self._creation_task = self.loop.create_task(self._create_session(**session_kwargs)) async def maybe_raise_for_status(self, response: aiohttp.ClientResponse, should_raise: bool) -> None: diff --git a/bot/bot.py b/bot/bot.py index 088b94a1f..3094a27c5 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -97,4 +97,4 @@ class Bot(commands.Bot): ) self.http_session = aiohttp.ClientSession(connector=self._connector) - self.api_client.recreate(connector=self._connector) + self.api_client.recreate(force=True, connector=self._connector) -- cgit v1.2.3 From 02f5e2fe019f332cb6b9e79b63fee54e41b66732 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Feb 2020 12:51:37 -0800 Subject: API: close existing open session before recreating it --- bot/api.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/api.py b/bot/api.py index 37c1497fc..8cb429cd5 100644 --- a/bot/api.py +++ b/bot/api.py @@ -60,16 +60,17 @@ class APIClient: Create the aiohttp session with `session_kwargs` and set the ready event. `session_kwargs` is merged with `_default_session_kwargs` and overwrites its values. + If an open session already exists, it will first be closed. """ + await self.close() self.session = aiohttp.ClientSession(**{**self._default_session_kwargs, **session_kwargs}) self._ready.set() async def close(self) -> None: """Close the aiohttp session and unset the ready event.""" - if not self._ready.is_set(): - return + if self.session: + await self.session.close() - await self.session.close() self._ready.clear() def recreate(self, force: bool = False, **session_kwargs) -> None: -- cgit v1.2.3 From 9fd7b0829162bb589b371215e5772b24d2bd7d38 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Sat, 29 Feb 2020 14:29:42 +0530 Subject: Added all the tag files in resources and modified cogs/tags.py file to access the static tag files rather than sending an API get request. Removed all methods calling the API so the tags cannot be edited, added nor deleted. --- bot/cogs/tags.py | 103 +++++----------------------- bot/resources/tags/args-kwargs.md | 17 +++++ bot/resources/tags/ask.md | 9 +++ bot/resources/tags/class.md | 25 +++++++ bot/resources/tags/classmethod.md | 20 ++++++ bot/resources/tags/codeblock.md | 17 +++++ bot/resources/tags/decorators.md | 31 +++++++++ bot/resources/tags/dictcomps.md | 20 ++++++ bot/resources/tags/enumerate.md | 13 ++++ bot/resources/tags/except.md | 17 +++++ bot/resources/tags/exit().md | 8 +++ bot/resources/tags/f-strings.md | 17 +++++ bot/resources/tags/foo.md | 10 +++ bot/resources/tags/functions-are-objects.md | 39 +++++++++++ bot/resources/tags/global.md | 16 +++++ bot/resources/tags/if-name-main.md | 26 +++++++ bot/resources/tags/indent.md | 24 +++++++ bot/resources/tags/inline.md | 16 +++++ bot/resources/tags/iterate-dict.md | 10 +++ bot/resources/tags/listcomps.md | 14 ++++ bot/resources/tags/mutable-default-args.md | 48 +++++++++++++ bot/resources/tags/names.md | 37 ++++++++++ bot/resources/tags/off-topic.md | 8 +++ bot/resources/tags/open.md | 26 +++++++ bot/resources/tags/or-gotcha.md | 17 +++++ bot/resources/tags/param-arg.md | 12 ++++ bot/resources/tags/paste.md | 6 ++ bot/resources/tags/pathlib.md | 21 ++++++ bot/resources/tags/pep8.md | 3 + bot/resources/tags/positional-keyword.md | 38 ++++++++++ bot/resources/tags/precedence.md | 13 ++++ bot/resources/tags/quotes.md | 20 ++++++ bot/resources/tags/relative-path.md | 7 ++ bot/resources/tags/repl.md | 13 ++++ bot/resources/tags/return.md | 35 ++++++++++ bot/resources/tags/round.md | 24 +++++++ bot/resources/tags/scope.md | 24 +++++++ bot/resources/tags/seek.md | 22 ++++++ bot/resources/tags/self.md | 25 +++++++ bot/resources/tags/star-imports.md | 48 +++++++++++++ bot/resources/tags/traceback.md | 18 +++++ bot/resources/tags/windows-path.md | 30 ++++++++ bot/resources/tags/with.md | 8 +++ bot/resources/tags/xy-problem.md | 7 ++ bot/resources/tags/ytdl.md | 9 +++ bot/resources/tags/zen.md | 20 ++++++ bot/resources/tags/zip.md | 12 ++++ 47 files changed, 919 insertions(+), 84 deletions(-) create mode 100644 bot/resources/tags/args-kwargs.md create mode 100644 bot/resources/tags/ask.md create mode 100644 bot/resources/tags/class.md create mode 100644 bot/resources/tags/classmethod.md create mode 100644 bot/resources/tags/codeblock.md create mode 100644 bot/resources/tags/decorators.md create mode 100644 bot/resources/tags/dictcomps.md create mode 100644 bot/resources/tags/enumerate.md create mode 100644 bot/resources/tags/except.md create mode 100644 bot/resources/tags/exit().md create mode 100644 bot/resources/tags/f-strings.md create mode 100644 bot/resources/tags/foo.md create mode 100644 bot/resources/tags/functions-are-objects.md create mode 100644 bot/resources/tags/global.md create mode 100644 bot/resources/tags/if-name-main.md create mode 100644 bot/resources/tags/indent.md create mode 100644 bot/resources/tags/inline.md create mode 100644 bot/resources/tags/iterate-dict.md create mode 100644 bot/resources/tags/listcomps.md create mode 100644 bot/resources/tags/mutable-default-args.md create mode 100644 bot/resources/tags/names.md create mode 100644 bot/resources/tags/off-topic.md create mode 100644 bot/resources/tags/open.md create mode 100644 bot/resources/tags/or-gotcha.md create mode 100644 bot/resources/tags/param-arg.md create mode 100644 bot/resources/tags/paste.md create mode 100644 bot/resources/tags/pathlib.md create mode 100644 bot/resources/tags/pep8.md create mode 100644 bot/resources/tags/positional-keyword.md create mode 100644 bot/resources/tags/precedence.md create mode 100644 bot/resources/tags/quotes.md create mode 100644 bot/resources/tags/relative-path.md create mode 100644 bot/resources/tags/repl.md create mode 100644 bot/resources/tags/return.md create mode 100644 bot/resources/tags/round.md create mode 100644 bot/resources/tags/scope.md create mode 100644 bot/resources/tags/seek.md create mode 100644 bot/resources/tags/self.md create mode 100644 bot/resources/tags/star-imports.md create mode 100644 bot/resources/tags/traceback.md create mode 100644 bot/resources/tags/windows-path.md create mode 100644 bot/resources/tags/with.md create mode 100644 bot/resources/tags/xy-problem.md create mode 100644 bot/resources/tags/ytdl.md create mode 100644 bot/resources/tags/zen.md create mode 100644 bot/resources/tags/zip.md diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index b6360dfae..0e959b45f 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -1,22 +1,22 @@ import logging +import os import re import time +from pathlib import Path from typing import Dict, List, Optional from discord import Colour, Embed from discord.ext.commands import Cog, Context, group from bot.bot import Bot -from bot.constants import Channels, Cooldowns, MODERATION_ROLES, Roles -from bot.converters import TagContentConverter, TagNameConverter -from bot.decorators import with_role +from bot.constants import Channels, Cooldowns +from bot.converters import TagNameConverter from bot.pagination import LinePaginator log = logging.getLogger(__name__) TEST_CHANNELS = ( - Channels.devtest, - Channels.bot, + Channels.bot_commands, Channels.helpers ) @@ -29,7 +29,6 @@ class Tags(Cog): def __init__(self, bot: Bot): self.bot = bot self.tag_cooldowns = {} - self._cache = {} self._last_fetch: float = 0.0 @@ -38,12 +37,23 @@ class Tags(Cog): # refresh only when there's a more than 5m gap from last call. time_now: float = time.time() if is_forced or not self._last_fetch or time_now - self._last_fetch > 5 * 60: - tags = await self.bot.api_client.get('bot/tags') - self._cache = {tag['title'].lower(): tag for tag in tags} + tag_files = os.listdir("bot/resources/tags") + for file in tag_files: + p = Path("bot", "resources", "tags", file) + tag_title = os.path.splitext(file)[0].lower() + with p.open() as f: + tag = { + "title": tag_title, + "embed": { + "description": f.read() + } + } + self._cache[tag_title] = tag + self._last_fetch = time_now @staticmethod - def _fuzzy_search(search: str, target: str) -> int: + def _fuzzy_search(search: str, target: str) -> float: """A simple scoring algorithm based on how many letters are found / total, with order in mind.""" current, index = 0, 0 _search = REGEX_NON_ALPHABET.sub('', search.lower()) @@ -159,81 +169,6 @@ class Tags(Cog): max_lines=15 ) - @tags_group.command(name='set', aliases=('add', 's')) - @with_role(*MODERATION_ROLES) - async def set_command( - self, - ctx: Context, - tag_name: TagNameConverter, - *, - tag_content: TagContentConverter, - ) -> None: - """Create a new tag.""" - body = { - 'title': tag_name.lower().strip(), - 'embed': { - 'title': tag_name, - 'description': tag_content - } - } - - await self.bot.api_client.post('bot/tags', json=body) - self._cache[tag_name.lower()] = await self.bot.api_client.get(f'bot/tags/{tag_name}') - - log.debug(f"{ctx.author} successfully added the following tag to our database: \n" - f"tag_name: {tag_name}\n" - f"tag_content: '{tag_content}'\n") - - await ctx.send(embed=Embed( - title="Tag successfully added", - description=f"**{tag_name}** added to tag database.", - colour=Colour.blurple() - )) - - @tags_group.command(name='edit', aliases=('e', )) - @with_role(*MODERATION_ROLES) - async def edit_command( - self, - ctx: Context, - tag_name: TagNameConverter, - *, - tag_content: TagContentConverter, - ) -> None: - """Edit an existing tag.""" - body = { - 'embed': { - 'title': tag_name, - 'description': tag_content - } - } - - await self.bot.api_client.patch(f'bot/tags/{tag_name}', json=body) - self._cache[tag_name.lower()] = await self.bot.api_client.get(f'bot/tags/{tag_name}') - - log.debug(f"{ctx.author} successfully edited the following tag in our database: \n" - f"tag_name: {tag_name}\n" - f"tag_content: '{tag_content}'\n") - - await ctx.send(embed=Embed( - title="Tag successfully edited", - description=f"**{tag_name}** edited in the database.", - colour=Colour.blurple() - )) - - @tags_group.command(name='delete', aliases=('remove', 'rm', 'd')) - @with_role(Roles.admin, Roles.owner) - async def delete_command(self, ctx: Context, *, tag_name: TagNameConverter) -> None: - """Remove a tag from the database.""" - await self.bot.api_client.delete(f'bot/tags/{tag_name}') - self._cache.pop(tag_name.lower(), None) - - log.debug(f"{ctx.author} successfully deleted the tag called '{tag_name}'") - await ctx.send(embed=Embed( - title=tag_name, - description=f"Tag successfully removed: {tag_name}.", - colour=Colour.blurple() - )) - def setup(bot: Bot) -> None: """Load the Tags cog.""" diff --git a/bot/resources/tags/args-kwargs.md b/bot/resources/tags/args-kwargs.md new file mode 100644 index 000000000..fb19d39fd --- /dev/null +++ b/bot/resources/tags/args-kwargs.md @@ -0,0 +1,17 @@ +`*args` and `**kwargs` + +These special parameters allow functions to take arbitrary amounts of positional and keyword arguments. The names `args` and `kwargs` are purely convention, and could be named any other valid variable name. The special functionality comes from the single and double asterisks (`*`). If both are used in a function signature, `*args` **must** appear before `**kwargs`. + +**Single asterisk** +`*args` will ingest an arbitrary amount of **positional arguments**, and store it in a tuple. If there are parameters after `*args` in the parameter list with no default value, they will become **required** keyword arguments by default. + +**Double asterisk** +`**kwargs` will ingest an arbitrary amount of **keyword arguments**, and store it in a dictionary. There can be **no** additional parameters **after** `**kwargs` in the parameter list. + +**Use cases** +• **Decorators** (see `!tags decorators`) +• **Inheritance** (overriding methods) +• **Future proofing** (in the case of the first two bullet points, if the parameters change, your code won't break) +• **Flexibility** (writing functions that behave like `dict()` or `print()`) + +*See* `!tags positional-keyword` *for information about positional and keyword arguments* \ No newline at end of file diff --git a/bot/resources/tags/ask.md b/bot/resources/tags/ask.md new file mode 100644 index 000000000..07f9bd84d --- /dev/null +++ b/bot/resources/tags/ask.md @@ -0,0 +1,9 @@ +Asking good questions will yield a much higher chance of a quick response: + +• Don't ask to ask your question, just go ahead and tell us your problem. +• Don't ask if anyone is knowledgeable in some area, filtering serves no purpose. +• Try to solve the problem on your own first, we're not going to write code for you. +• Show us the code you've tried and any errors or unexpected results it's giving. +• Be patient while we're helping you. + +You can find a much more detailed explanation [on our website](https://pythondiscord.com/pages/asking-good-questions/). \ No newline at end of file diff --git a/bot/resources/tags/class.md b/bot/resources/tags/class.md new file mode 100644 index 000000000..74c36b9fa --- /dev/null +++ b/bot/resources/tags/class.md @@ -0,0 +1,25 @@ +**Classes** + +Classes are used to create objects that have specific behavior. + +Every object in python has a class, including `list`s, `dict`ionaries and even numbers. Using a class to group code and data like this is the foundation of Object Oriented Programming. Classes allow you to expose a simple, consistent interface while hiding the more complicated details. This simplifies the rest of your program and makes it easier to separately maintain and debug each component. + +Here is an example class: + +```python +class Foo: + def __init__(self, somedata): + self.my_attrib = somedata + + def show(self): + print(self.my_attrib) +``` + +To use a class, you need to instantiate it. The following creates a new object named `bar`, with `Foo` as its class. + +```python +bar = Foo('data') +bar.show() +``` + +We can access any of `Foo`'s methods via `bar.my_method()`, and access any of `bar`s data via `bar.my_attribute`. \ No newline at end of file diff --git a/bot/resources/tags/classmethod.md b/bot/resources/tags/classmethod.md new file mode 100644 index 000000000..43c6d9909 --- /dev/null +++ b/bot/resources/tags/classmethod.md @@ -0,0 +1,20 @@ +Although most methods are tied to an _object instance_, it can sometimes be useful to create a method that does something with _the class itself_. To achieve this in Python, you can use the `@classmethod` decorator. This is often used to provide alternative constructors for a class. + +For example, you may be writing a class that takes some magic token (like an API key) as a constructor argument, but you sometimes read this token from a configuration file. You could make use of a `@classmethod` to create an alternate constructor for when you want to read from the configuration file. +```py +class Bot: + def __init__(self, token: str): + self._token = token + + @classmethod + def from_config(cls, config: dict) -> Bot: + token = config['token'] + return cls(token) + +# now we can create the bot instance like this +alternative_bot = Bot.from_config(default_config) + +# but this still works, too +regular_bot = Bot("tokenstring") +``` +This is just one of the many use cases of `@classmethod`. A more in-depth explanation can be found [here](https://stackoverflow.com/questions/12179271/meaning-of-classmethod-and-staticmethod-for-beginner#12179752). \ No newline at end of file diff --git a/bot/resources/tags/codeblock.md b/bot/resources/tags/codeblock.md new file mode 100644 index 000000000..816bb8232 --- /dev/null +++ b/bot/resources/tags/codeblock.md @@ -0,0 +1,17 @@ +Discord has support for Markdown, which allows you to post code with full syntax highlighting. Please use these whenever you paste code, as this helps improve the legibility and makes it easier for us to help you. + +To do this, use the following method: + +\```python +print('Hello world!') +\``` + +Note: +• **These are backticks, not quotes.** Backticks can usually be found on the tilde key. +• You can also use py as the language instead of python +• The language must be on the first line next to the backticks with **no** space between them + +This will result in the following: +```py +print('Hello world!') +``` \ No newline at end of file diff --git a/bot/resources/tags/decorators.md b/bot/resources/tags/decorators.md new file mode 100644 index 000000000..3ff1db16c --- /dev/null +++ b/bot/resources/tags/decorators.md @@ -0,0 +1,31 @@ +**Decorators** + +A decorator is a function that modifies another function. + +Consider the following example of a timer decorator: +```py +>>> import time +>>> def timer(f): +... def inner(*args, **kwargs): +... start = time.time() +... result = f(*args, **kwargs) +... print('Time elapsed:', time.time() - start) +... return result +... return inner +... +>>> @timer +... def slow(delay=1): +... time.sleep(delay) +... return 'Finished!' +... +>>> print(slow()) +Time elapsed: 1.0011568069458008 +Finished! +>>> print(slow(3)) +Time elapsed: 3.000307321548462 +Finished! +``` + +More information: +• [Corey Schafer's video on decorators](https://youtu.be/FsAPt_9Bf3U) +• [Real python article](https://realpython.com/primer-on-python-decorators/) \ No newline at end of file diff --git a/bot/resources/tags/dictcomps.md b/bot/resources/tags/dictcomps.md new file mode 100644 index 000000000..ddefa1299 --- /dev/null +++ b/bot/resources/tags/dictcomps.md @@ -0,0 +1,20 @@ +**Dictionary Comprehensions** + +Like lists, there is a convenient way of creating dictionaries: +```py +>>> ftoc = {f: round((5/9)*(f-32)) for f in range(-40,101,20)} +>>> print(ftoc) +{-40: -40, -20: -29, 0: -18, 20: -7, 40: 4, 60: 16, 80: 27, 100: 38} +``` +In the example above, I created a dictionary of temperatures in Fahrenheit, that are mapped to (*roughly*) their Celsius counterpart within a small range. These comprehensions are useful for succinctly creating dictionaries from some other sequence. + +They are also very useful for inverting the key value pairs of a dictionary that already exists, such that the value in the old dictionary is now the key, and the corresponding key is now its value: +```py +>>> ctof = {v:k for k, v in ftoc.items()} +>>> print(ctof) +{-40: -40, -29: -20, -18: 0, -7: 20, 4: 40, 16: 60, 27: 80, 38: 100} +``` + +Also like list comprehensions, you can add a conditional to it in order to filter out items you don't want. + +For more information and examples, check [PEP 274](https://www.python.org/dev/peps/pep-0274/) \ No newline at end of file diff --git a/bot/resources/tags/enumerate.md b/bot/resources/tags/enumerate.md new file mode 100644 index 000000000..610843cf4 --- /dev/null +++ b/bot/resources/tags/enumerate.md @@ -0,0 +1,13 @@ +Ever find yourself in need of the current iteration number of your `for` loop? You should use **enumerate**! Using `enumerate`, you can turn code that looks like this: +```py +index = 0 +for item in my_list: + print(f"{index}: {item}") + index += 1 +``` +into beautiful, _pythonic_ code: +```py +for index, item in enumerate(my_list): + print(f"{index}: {item}") +``` +For more information, check out [the official docs](https://docs.python.org/3/library/functions.html#enumerate), or [PEP 279](https://www.python.org/dev/peps/pep-0279/). \ No newline at end of file diff --git a/bot/resources/tags/except.md b/bot/resources/tags/except.md new file mode 100644 index 000000000..66dce13ab --- /dev/null +++ b/bot/resources/tags/except.md @@ -0,0 +1,17 @@ +A key part of the Python philosophy is to ask for forgiveness, not permission. This means that it's okay to write code that may produce an error, as long as you specify how that error should be handled. Code written this way is readable and resilient. +```py +try: + number = int(user_input) +except ValueError: + print("failed to convert user_input to a number. setting number to 0.") + number = 0 +``` +You should always specify the exception type if it is possible to do so, and your `try` block should be as short as possible. Attempting to handle broad categories of unexpected exceptions can silently hide serious problems. +```py +try: + number = int(user_input) + item = some_list[number] +except: + print("An exception was raised, but we have no idea if it was a ValueError or an IndexError.") +``` +For more information about exception handling, see [the official Python docs](https://docs.python.org/3/tutorial/errors.html), or watch [Corey Schafer's video on exception handling](https://www.youtube.com/watch?v=NIWwJbo-9_8). \ No newline at end of file diff --git a/bot/resources/tags/exit().md b/bot/resources/tags/exit().md new file mode 100644 index 000000000..89f83f7e0 --- /dev/null +++ b/bot/resources/tags/exit().md @@ -0,0 +1,8 @@ +**Exiting Programmatically** + +If you want to exit your code programmatically, you might think to use the functions `exit()` or `quit()`, however this is bad practice. These functions are constants added by the [`site`](https://docs.python.org/3/library/site.html#module-site) module as a convenient method for exiting the interactive interpreter shell, and should not be used in programs. + +You should use either [`SystemExit`](https://docs.python.org/3/library/exceptions.html#SystemExit) or [`sys.exit()`](https://docs.python.org/3/library/sys.html#sys.exit) instead. +There's not much practical difference between these two other than having to `import sys` for the latter. Both take an optional argument to provide an exit status. + +[Official documentation](https://docs.python.org/3/library/constants.html#exit) with the warning not to use `exit()` or `quit()` in source code. \ No newline at end of file diff --git a/bot/resources/tags/f-strings.md b/bot/resources/tags/f-strings.md new file mode 100644 index 000000000..966fe6080 --- /dev/null +++ b/bot/resources/tags/f-strings.md @@ -0,0 +1,17 @@ +In Python, there are several ways to do string interpolation, including using `%s`\'s and by using the `+` operator to concatenate strings together. However, because some of these methods offer poor readability and require typecasting to prevent errors, you should for the most part be using a feature called format strings. + +**In Python 3.6 or later, we can use f-strings like this:** +```py +snake = "Pythons" +print(f"{snake} are some of the largest snakes in the world") +``` +**In earlier versions of Python or in projects where backwards compatibility is very important, use str.format() like this:** +```py +snake = "Pythons" + +# With str.format() you can either use indexes +print("{0} are some of the largest snakes in the world".format(snake)) + +# Or keyword arguments +print("{family} are some of the largest snakes in the world".format(family=snake)) +``` \ No newline at end of file diff --git a/bot/resources/tags/foo.md b/bot/resources/tags/foo.md new file mode 100644 index 000000000..58bc4b78f --- /dev/null +++ b/bot/resources/tags/foo.md @@ -0,0 +1,10 @@ +**Metasyntactic variables** + +A specific word or set of words identified as a placeholder used in programming. They are used to name entities such as variables, functions, etc, whose exact identity is unimportant and serve only to demonstrate a concept, which is useful for teaching programming. + +Common examples include `foobar`, `foo`, `bar`, `baz`, and `qux`. +Python has its own metasyntactic variables, namely `spam`, `eggs`, and `bacon`. This is a reference to a [Monty Python](https://en.wikipedia.org/wiki/Monty_Python) sketch (the eponym of the language). + +More information: +• [History of foobar](https://en.wikipedia.org/wiki/Foobar) +• [Monty Python sketch](https://en.wikipedia.org/wiki/Spam_%28Monty_Python%29) \ No newline at end of file diff --git a/bot/resources/tags/functions-are-objects.md b/bot/resources/tags/functions-are-objects.md new file mode 100644 index 000000000..d10e6b73e --- /dev/null +++ b/bot/resources/tags/functions-are-objects.md @@ -0,0 +1,39 @@ +**Calling vs. Referencing functions** + +When assigning a new name to a function, storing it in a container, or passing it as an argument, a common mistake made is to call the function. Instead of getting the actual function, you'll get its return value. + +In Python you can treat function names just like any other variable. Assume there was a function called `now` that returns the current time. If you did `x = now()`, the current time would be assigned to `x`, but if you did `x = now`, the function `now` itself would be assigned to `x`. `x` and `now` would both equally reference the function. + +**Examples** +```py +# assigning new name + +def foo(): + return 'bar' + +def spam(): + return 'eggs' + +baz = foo +baz() # returns 'bar' + +ham = spam +ham() # returns 'eggs' +``` +```py +# storing in container + +import math +functions = [math.sqrt, math.factorial, math.log] +functions[0](25) # returns 5.0 +# the above equivalent to math.sqrt(25) +``` +```py +# passing as argument + +class C: + builtin_open = staticmethod(open) + +# open function is passed +# to the staticmethod class +``` \ No newline at end of file diff --git a/bot/resources/tags/global.md b/bot/resources/tags/global.md new file mode 100644 index 000000000..fc60f9177 --- /dev/null +++ b/bot/resources/tags/global.md @@ -0,0 +1,16 @@ +When adding functions or classes to a program, it can be tempting to reference inaccessible variables by declaring them as global. Doing this can result in code that is harder to read, debug and test. Instead of using globals, pass variables or objects as parameters and receive return values. + +Instead of writing +```py +def update_score(): + global score, roll + score = score + roll +update_score() +``` +do this instead +```py +def update_score(score, roll): + return score + roll +score = update_score(score, roll) +``` +For in-depth explanations on why global variables are bad news in a variety of situations, see [this Stack Overflow answer](https://stackoverflow.com/questions/19158339/why-are-global-variables-evil/19158418#19158418). \ No newline at end of file diff --git a/bot/resources/tags/if-name-main.md b/bot/resources/tags/if-name-main.md new file mode 100644 index 000000000..d44f0086d --- /dev/null +++ b/bot/resources/tags/if-name-main.md @@ -0,0 +1,26 @@ +`if __name__ == '__main__'` + +This is a statement that is only true if the module (your source code) it appears in is being run directly, as opposed to being imported into another module. When you run your module, the `__name__` special variable is automatically set to the string `'__main__'`. Conversely, when you import that same module into a different one, and run that, `__name__` is instead set to the filename of your module minus the `.py` extension. + +**Example** +```py +# foo.py + +print('spam') + +if __name__ == '__main__': + print('eggs') +``` +If you run the above module `foo.py` directly, both `'spam'`and `'eggs'` will be printed. Now consider this next example: +```py +# bar.py + +import foo +``` +If you run this module named `bar.py`, it will execute the code in `foo.py`. First it will print `'spam'`, and then the `if` statement will fail, because `__name__` will now be the string `'foo'`. + +**Why would I do this?** + +• Your module is a library, but also has a special case where it can be run directly +• Your module is a library and you want to safeguard it against people running it directly (like what `pip` does) +• Your module is the main program, but has unit tests and the testing framework works by importing your module, and you want to avoid having your main code run during the test \ No newline at end of file diff --git a/bot/resources/tags/indent.md b/bot/resources/tags/indent.md new file mode 100644 index 000000000..5b36a4818 --- /dev/null +++ b/bot/resources/tags/indent.md @@ -0,0 +1,24 @@ +**Indentation** + +Indentation is leading whitespace (spaces and tabs) at the beginning of a line of code. In the case of Python, they are used to determine the grouping of statements. + +Spaces should be preferred over tabs. To be clear, this is in reference to the character itself, not the keys on a keyboard. Your editor/IDE should be configured to insert spaces when the TAB key is pressed. The amount of spaces should be a multiple of 4, except optionally in the case of continuation lines. + +**Example** +```py +def foo(): + bar = 'baz' # indented one level + if bar == 'baz': + print('ham') # indented two levels + return bar # indented one level +``` +The first line is not indented. The next two lines are indented to be inside of the function definition. They will only run when the function is called. The fourth line is indented to be inside the `if` statement, and will only run if the `if` statement evaluates to `True`. The fifth and last line is like the 2nd and 3rd and will always run when the function is called. It effectively closes the `if` statement above as no more lines can be inside the `if` statement below that line. + +**Indentation is used after:** +**1.** [Compound statements](https://docs.python.org/3/reference/compound_stmts.html) (eg. `if`, `while`, `for`, `try`, `with`, `def`, `class`, and their counterparts) +**2.** [Continuation lines](https://www.python.org/dev/peps/pep-0008/#indentation) + +**More Info** +**1.** [Indentation style guide](https://www.python.org/dev/peps/pep-0008/#indentation) +**2.** [Tabs or Spaces?](https://www.python.org/dev/peps/pep-0008/#tabs-or-spaces) +**3.** [Official docs on indentation](https://docs.python.org/3/reference/lexical_analysis.html#indentation) \ No newline at end of file diff --git a/bot/resources/tags/inline.md b/bot/resources/tags/inline.md new file mode 100644 index 000000000..4670256bc --- /dev/null +++ b/bot/resources/tags/inline.md @@ -0,0 +1,16 @@ +**Inline codeblocks** + +In addition to multi-line codeblocks, discord has support for inline codeblocks as well. These are small codeblocks that are usually a single line, that can fit between non-codeblocks on the same line. + +The following is an example of how it's done: + +The \`\_\_init\_\_\` method customizes the newly created instance. + +And results in the following: + +The `__init__` method customizes the newly created instance. + +**Note:** +• These are **backticks** not quotes +• Avoid using them for multiple lines +• Useful for negating formatting you don't want \ No newline at end of file diff --git a/bot/resources/tags/iterate-dict.md b/bot/resources/tags/iterate-dict.md new file mode 100644 index 000000000..b23475506 --- /dev/null +++ b/bot/resources/tags/iterate-dict.md @@ -0,0 +1,10 @@ +There are two common ways to iterate over a dictionary in Python. To iterate over the keys: +```py +for key in my_dict: + print(key) +``` +To iterate over both the keys and values: +```py +for key, val in my_dict.items(): + print(key, val) +``` \ No newline at end of file diff --git a/bot/resources/tags/listcomps.md b/bot/resources/tags/listcomps.md new file mode 100644 index 000000000..5ef0ce2bc --- /dev/null +++ b/bot/resources/tags/listcomps.md @@ -0,0 +1,14 @@ +Do you ever find yourself writing something like: +```py +even_numbers = [] +for n in range(20): + if n % 2 == 0: + even_numbers.append(n) +``` +Using list comprehensions can simplify this significantly, and greatly improve code readability. If we rewrite the example above to use list comprehensions, it would look like this: +```py +even_numbers = [n for n in range(20) if n % 2 == 0] +``` +This also works for generators, dicts and sets by using `()` or `{}` instead of `[]`. + +For more info, see [this pythonforbeginners.com post](http://www.pythonforbeginners.com/basics/list-comprehensions-in-python) or [PEP 202](https://www.python.org/dev/peps/pep-0202/). \ No newline at end of file diff --git a/bot/resources/tags/mutable-default-args.md b/bot/resources/tags/mutable-default-args.md new file mode 100644 index 000000000..49f536b78 --- /dev/null +++ b/bot/resources/tags/mutable-default-args.md @@ -0,0 +1,48 @@ +**Mutable Default Arguments** + +Default arguments in python are evaluated *once* when the function is +**defined**, *not* each time the function is **called**. This means that if +you have a mutable default argument and mutate it, you will have +mutated that object for all future calls to the function as well. + +For example, the following `append_one` function appends `1` to a list +and returns it. `foo` is set to an empty list by default. +```python +>>> def append_one(foo=[]): +... foo.append(1) +... return foo +... +``` +See what happens when we call it a few times: +```python +>>> append_one() +[1] +>>> append_one() +[1, 1] +>>> append_one() +[1, 1, 1] +``` +Each call appends an additional `1` to our list `foo`. It does not +receive a new empty list on each call, it is the same list everytime. + +To avoid this problem, you have to create a new object every time the +function is **called**: +```python +>>> def append_one(foo=None): +... if foo is None: +... foo = [] +... foo.append(1) +... return foo +... +>>> append_one() +[1] +>>> append_one() +[1] +``` + +**Note**: + +• This behavior can be used intentionally to maintain state between +calls of a function (eg. when writing a caching function). +• This behavior is not unique to mutable objects, all default +arguments are evaulated only once when the function is defined. \ No newline at end of file diff --git a/bot/resources/tags/names.md b/bot/resources/tags/names.md new file mode 100644 index 000000000..b7b914d53 --- /dev/null +++ b/bot/resources/tags/names.md @@ -0,0 +1,37 @@ +**Naming and Binding** + +A name is a piece of text that is bound to an object. They are a **reference** to an object. Examples are function names, class names, module names, variables, etc. + +**Note:** Names **cannot** reference other names, and assignment **never** creates a copy. +```py +x = 1 # x is bound to 1 +y = x # y is bound to VALUE of x +x = 2 # x is bound to 2 +print(x, y) # 2 1 +``` +When doing `y = x`, the name `y` is being bound to the *value* of `x` which is `1`. Neither `x` nor `y` are the 'real' name. The object `1` simply has *multiple* names. They are the exact same object. +``` +>>> x = 1 +x ━━ 1 + +>>> y = x +x ━━ 1 +y ━━━┛ + +>>> x = 2 +x ━━ 2 +y ━━ 1 +``` +**Names are created in multiple ways** +You might think that the only way to bind a name to an object is by using assignment, but that isn't the case. All of the following work exactly the same as assignment: +• `import` statements +• `class` and `def` +• `for` loop headers +• `as` keyword when used with `except`, `import`, and `with` +• formal parameters in function headers + +There is also `del` which has the purpose of *unbinding* a name. + +**More info** +• Please watch [Ned Batchelder's talk](https://youtu.be/_AEJHKGk9ns) on names in python for a detailed explanation with examples +• [Official documentation](https://docs.python.org/3/reference/executionmodel.html#naming-and-binding) \ No newline at end of file diff --git a/bot/resources/tags/off-topic.md b/bot/resources/tags/off-topic.md new file mode 100644 index 000000000..8fa70bf6e --- /dev/null +++ b/bot/resources/tags/off-topic.md @@ -0,0 +1,8 @@ +**Off-topic channels** + +There are three off-topic channels: +• <#291284109232308226> +• <#463035241142026251> +• <#463035268514185226> + +Their names change randomly every 24 hours, but you can always find them under the `OFF-TOPIC/GENERAL` category in the channel list. \ No newline at end of file diff --git a/bot/resources/tags/open.md b/bot/resources/tags/open.md new file mode 100644 index 000000000..74150dbc7 --- /dev/null +++ b/bot/resources/tags/open.md @@ -0,0 +1,26 @@ +**Opening files** + +The built-in function `open()` is one of several ways to open files on your computer. It accepts many different parameters, so this tag will only go over two of them (`file` and `mode`). For more extensive documentation on all these parameters, consult the [official documentation](https://docs.python.org/3/library/functions.html#open). The object returned from this function is a [file object or stream](https://docs.python.org/3/glossary.html#term-file-object), for which the full documentation can be found [here](https://docs.python.org/3/library/io.html#io.TextIOBase). + +See also: +• `!tags with` for information on context managers +• `!tags pathlib` for an alternative way of opening files +• `!tags seek` for information on changing your position in a file + +**The `file` parameter** + +This should be a [path-like object](https://docs.python.org/3/glossary.html#term-path-like-object) denoting the name or path (absolute or relative) to the file you want to open. + +An absolute path is the full path from your root directory to the file you want to open. Generally this is the option you should choose so it doesn't matter what directory you're in when you execute your module. + +See `!tags relative-path` for more information on relative paths. + +**The `mode` parameter** + +This is an optional string that specifies the mode in which the file should be opened. There's not enough room to discuss them all, but listed below are some of the more confusing modes. + +`'r+'` Opens for reading and writing (file must already exist) +`'w+'` Opens for reading and writing and truncates (can create files) +`'x'` Creates file and opens for writing (file must **not** already exist) +`'x+'` Creates file and opens for reading and writing (file must **not** already exist) +`'a+'` Opens file for reading and writing at **end of file** (can create files) \ No newline at end of file diff --git a/bot/resources/tags/or-gotcha.md b/bot/resources/tags/or-gotcha.md new file mode 100644 index 000000000..da82e3fdd --- /dev/null +++ b/bot/resources/tags/or-gotcha.md @@ -0,0 +1,17 @@ +When checking if something is equal to one thing or another, you might think that this is possible: +```py +if favorite_fruit == 'grapefruit' or 'lemon': + print("That's a weird favorite fruit to have.") +``` +After all, that's how you would normally phrase it in plain English. In Python, however, you have to have _complete instructions on both sides of the logical operator_. + +So, if you want to check if something is equal to one thing or another, there are two common ways: +```py +# Like this... +if favorite_fruit == 'grapefruit' or favorite_fruit == 'lemon': + print("That's a weird favorite fruit to have.") + +# ...or like this. +if favorite_fruit in ('grapefruit', 'lemon'): + print("That's a weird favorite fruit to have.") +``` \ No newline at end of file diff --git a/bot/resources/tags/param-arg.md b/bot/resources/tags/param-arg.md new file mode 100644 index 000000000..9e946812b --- /dev/null +++ b/bot/resources/tags/param-arg.md @@ -0,0 +1,12 @@ +**Parameters vs. Arguments** + +A parameter is a variable defined in a function signature (the line with `def` in it), while arguments are objects passed to a function call. + +```py +def square(n): # n is the parameter + return n*n + +print(square(5)) # 5 is the argument +``` + +Note that `5` is the argument passed to `square`, but `square(5)` in its entirety is the argument passed to `print` \ No newline at end of file diff --git a/bot/resources/tags/paste.md b/bot/resources/tags/paste.md new file mode 100644 index 000000000..d8e6e6c61 --- /dev/null +++ b/bot/resources/tags/paste.md @@ -0,0 +1,6 @@ +**Pasting large amounts of code** + +If your code is too long to fit in a codeblock in discord, you can paste your code here: +https://paste.pydis.com/ + +After pasting your code, **save** it by clicking the floppy disk icon in the top right, or by typing `ctrl + S`. After doing that, the URL should **change**. Copy the URL and post it here so others can see it. \ No newline at end of file diff --git a/bot/resources/tags/pathlib.md b/bot/resources/tags/pathlib.md new file mode 100644 index 000000000..37913951d --- /dev/null +++ b/bot/resources/tags/pathlib.md @@ -0,0 +1,21 @@ +**Pathlib** + +Python 3 comes with a new module named `Pathlib`. Since Python 3.6, `pathlib.Path` objects work nearly everywhere that `os.path` can be used, meaning you can integrate your new code directly into legacy code without having to rewrite anything. Pathlib makes working with paths way simpler than `os.path` does. + +**Feature spotlight**: + +• Normalizes file paths for all platforms automatically +• Has glob-like utilites (eg. `Path.glob`, `Path.rglob`) for searching files +• Can read and write files, and close them automatically +• Convenient syntax, utilising the `/` operator (e.g. `Path('~') / 'Documents'`) +• Can easily pick out components of a path (eg. name, parent, stem, suffix, anchor) +• Supports method chaining +• Move and delete files +• And much more + +**More Info**: + +• [**Why you should use pathlib** - Trey Hunner](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) +• [**Answering concerns about pathlib** - Trey Hunner](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) +• [**Official Documentation**](https://docs.python.org/3/library/pathlib.html) +• [**PEP 519** - Adding a file system path protocol](https://www.python.org/dev/peps/pep-0519/) \ No newline at end of file diff --git a/bot/resources/tags/pep8.md b/bot/resources/tags/pep8.md new file mode 100644 index 000000000..ec999bedc --- /dev/null +++ b/bot/resources/tags/pep8.md @@ -0,0 +1,3 @@ +**PEP 8** is the official style guide for Python. It includes comprehensive guidelines for code formatting, variable naming, and making your code easy to read. Professional Python developers are usually required to follow the guidelines, and will often use code-linters like `flake8` to verify that the code they\'re writing complies with the style guide. + +You can find the PEP 8 document [here](https://www.python.org/dev/peps/pep-0008). \ No newline at end of file diff --git a/bot/resources/tags/positional-keyword.md b/bot/resources/tags/positional-keyword.md new file mode 100644 index 000000000..3faec32ca --- /dev/null +++ b/bot/resources/tags/positional-keyword.md @@ -0,0 +1,38 @@ +**Positional vs. Keyword arguments** + +Functions can take two different kinds of arguments. A positional argument is just the object itself. A keyword argument is a name assigned to an object. + +**Example** +```py +>>> print('Hello', 'world!', sep=', ') +Hello, world! +``` +The first two strings `'Hello'` and `world!'` are positional arguments. +The `sep=', '` is a keyword argument. + +**Note** +A keyword argument can be passed positionally in some cases. +```py +def sum(a, b=1): + return a + b + +sum(1, b=5) +sum(1, 5) # same as above +``` +[Somtimes this is forced](https://www.python.org/dev/peps/pep-0570/#history-of-positional-only-parameter-semantics-in-python), in the case of the `pow()` function. + +The reverse is also true: +```py +>>> def foo(a, b): +... print(a, b) +... +>>> foo(a=1, b=2) +1 2 +>>> foo(b=1, a=2) +2 1 +``` + +**More info** +• [Keyword only arguments](https://www.python.org/dev/peps/pep-3102/) +• [Positional only arguments](https://www.python.org/dev/peps/pep-0570/) +• `!tags param-arg` (Parameters vs. Arguments) \ No newline at end of file diff --git a/bot/resources/tags/precedence.md b/bot/resources/tags/precedence.md new file mode 100644 index 000000000..8a4c66c4e --- /dev/null +++ b/bot/resources/tags/precedence.md @@ -0,0 +1,13 @@ +**Operator Precedence** + +Operator precedence is essentially like an order of operations for python's operators. + +**Example 1** (arithmetic) +`2 * 3 + 1` is `7` because multiplication is first +`2 * (3 + 1)` is `8` because the parenthesis change the precedence allowing the sum to be first + +**Example 2** (logic) +`not True or True` is `True` because the `not` is first +`not (True or True)` is `False` because the `or` is first + +The full table of precedence from lowest to highest is [here](https://docs.python.org/3/reference/expressions.html#operator-precedence) \ No newline at end of file diff --git a/bot/resources/tags/quotes.md b/bot/resources/tags/quotes.md new file mode 100644 index 000000000..609b6d2d2 --- /dev/null +++ b/bot/resources/tags/quotes.md @@ -0,0 +1,20 @@ +**String Quotes** + +Single and Double quoted strings are the **same** in Python. The choice of which one to use is up to you, just make sure that you **stick to that choice**. + +With that said, there are exceptions to this that are more important than consistency. If a single or double quote is needed *inside* the string, using the opposite quotation is better than using escape characters. + +Example: +```py +'My name is "Guido"' # good +"My name is \"Guido\"" # bad + +"Don't go in there" # good +'Don\'t go in there' # bad +``` +**Note:** +If you need both single and double quotes inside your string, use the version that would result in the least amount of escapes. In the case of a tie, use the quotation you use the most. + +**References:** +• [pep-8 on quotes](https://www.python.org/dev/peps/pep-0008/#string-quotes) +• [convention for triple quoted strings](https://www.python.org/dev/peps/pep-0257/) \ No newline at end of file diff --git a/bot/resources/tags/relative-path.md b/bot/resources/tags/relative-path.md new file mode 100644 index 000000000..269276e81 --- /dev/null +++ b/bot/resources/tags/relative-path.md @@ -0,0 +1,7 @@ +**Relative Path** + +A relative path is a partial path that is relative to your current working directory. A common misconception is that your current working directory is the location of the module you're executing, **but this is not the case**. Your current working directory is actually the **directory you were in when you ran the python interpreter**. The reason for this misconception is because a common way to run your code is to navigate to the directory your module is stored, and run `python .py`. Thus, in this case your current working directory will be the same as the location of the module. However, if we instead did `python path/to/.py`, our current working directory would no longer be the same as the location of the module we're executing. + +**Why is this important?** + +When opening files in python, relative paths won't always work since it's dependent on what directory you were in when you ran your code. A common issue people face is running their code in an IDE thinking they can open files that are in the same directory as their module, but the current working directory will be different than what they expect and so they won't find the file. The way to avoid this problem is by using absolute paths, which is the full path from your root directory to the file you want to open. \ No newline at end of file diff --git a/bot/resources/tags/repl.md b/bot/resources/tags/repl.md new file mode 100644 index 000000000..a68fe9397 --- /dev/null +++ b/bot/resources/tags/repl.md @@ -0,0 +1,13 @@ +**Read-Eval-Print Loop** + +A REPL is an interactive language shell environment. It first **reads** one or more expressions entered by the user, **evaluates** it, yields the result, and **prints** it out to the user. It will then **loop** back to the **read** step. + +To use python's REPL, execute the interpreter with no arguments. This will drop you into the interactive interpreter shell, print out some relevant information, and then prompt you with the primary prompt `>>>`. At this point it is waiting for your input. + +Firstly you can start typing in some valid python expressions, pressing to either bring you to the **eval** step, or prompting you with the secondary prompt `...` (or no prompt at all depending on your environment), meaning your expression isn't yet terminated and it's waiting for more input. This is useful for code that requires multiple lines like loops, functions, and classes. If you reach the secondary prompt in a clause that can have an arbitrary amount of expressions, you can terminate it by pressing on a blank line. In other words, for the last expression you write in the clause, must be pressed twice in a row. + +Alternatively, you can make use of the builtin `help()` function. `help(thing)` to get help on some `thing` object, or `help()` to start an interactive help session. This mode is extremely powerful, read the instructions when first entering the session to learn how to use it. + +Lastly you can run your code with the `-i` flag to execute your code normally, but be dropped into the REPL once execution is finished, giving you access to all your global variables/functions in the REPL. + +To **exit** either a help session, or normal REPL prompt, you must send an EOF signal to the prompt. In *nix systems, this is done with `ctrl + D`, and in windows systems it is `ctrl + Z`. You can also exit the normal REPL prompt with the dedicated functions `exit()` or `quit()`. \ No newline at end of file diff --git a/bot/resources/tags/return.md b/bot/resources/tags/return.md new file mode 100644 index 000000000..7e0cdaa98 --- /dev/null +++ b/bot/resources/tags/return.md @@ -0,0 +1,35 @@ +**Return Statement** + +When calling a function, you'll often want it to give you a value back. In order to do that, you must `return` it. The reason for this is because functions have their own scope. Any values defined within the function body are inaccessible outside of that function. + +*For more information about scope, see `!tags scope`* + +Consider the following function: +```py +def square(n): + return n*n +``` +If we wanted to store 5 squared in a variable called `x`, we could do that like so: +`x = square(5)`. `x` would now equal `25`. + +**Common Mistakes** +```py +>>> def square(n): +... n*n # calculates then throws away, returns None +... +>>> x = square(5) +>>> print(x) +None +>>> def square(n): +... print(n*n) # calculates and prints, then throws away and returns None +... +>>> x = square(5) +25 +>>> print(x) +None +``` +**Things to note** +• `print()` and `return` do **not** accomplish the same thing. `print()` will only print the value, it will not be accessible outside of the function afterwards. +• A function will return `None` if it ends without reaching an explicit `return` statement. +• When you want to print a value calculated in a function, instead of printing inside the function, it is often better to return the value and print the *function call* instead. +• [Official documentation for `return`](https://docs.python.org/3/reference/simple_stmts.html#the-return-statement) \ No newline at end of file diff --git a/bot/resources/tags/round.md b/bot/resources/tags/round.md new file mode 100644 index 000000000..3e33c8ff7 --- /dev/null +++ b/bot/resources/tags/round.md @@ -0,0 +1,24 @@ +**Round half to even** + +Python 3 uses bankers' rounding (also known by other names), where if the fractional part of a number is `.5`, it's rounded to the nearest **even** result instead of away from zero. + +Example: +```py +>>> round(2.5) +2 +>>> round(1.5) +2 +``` +In the first example, there is a tie between 2 and 3, and since 3 is odd and 2 is even, the result is 2. +In the second example, the tie is between 1 and 2, and so 2 is also the result. + +**Why this is done:** +The round half up technique creates a slight bias towards the larger number. With a large amount of calculations, this can be significant. The round half to even technique eliminates this bias. + +It should be noted that round half to even distorts the distribution by increasing the probability of evens relative to odds, however this is considered less important than the bias explained above. + +**References:** +• [Wikipedia article about rounding](https://en.wikipedia.org/wiki/Rounding#Round_half_to_even) +• [Documentation on `round` function](https://docs.python.org/3/library/functions.html#round) +• [`round` in what's new in python 3](https://docs.python.org/3/whatsnew/3.0.html#builtins) (4th bullet down) +• [How to force rounding technique](https://stackoverflow.com/a/10826537/4607272) \ No newline at end of file diff --git a/bot/resources/tags/scope.md b/bot/resources/tags/scope.md new file mode 100644 index 000000000..ff9d96637 --- /dev/null +++ b/bot/resources/tags/scope.md @@ -0,0 +1,24 @@ +**Scoping Rules** + +A *scope* defines the visibility of a name within a block, where a block is a piece of python code executed as a unit. For simplicity, this would be a module, a function body, and a class definition. A name refers to text bound to an object. + +*For more information about names, see `!tags names`* + +A module is the source code file itself, and encompasses all blocks defined within it. Therefore if a variable is defined at the module level (top-level code block), it is a global variable and can be accessed anywhere in the module as long as the block in which it's referenced is executed after it was defined. + +Alternatively if a variable is defined within a function block for example, it is a local variable. It is not accessible at the module level, as that would be *outside* its scope. This is the purpose of the `return` statement, as it hands an object back to the scope of its caller. Conversely if a function was defined *inside* the previously mentioned block, it *would* have access to that variable, because it is within the first function's scope. +```py +>>> def outer(): +... foo = 'bar' # local variable to outer +... def inner(): +... print(foo) # has access to foo from scope of outer +... return inner # brings inner to scope of caller +... +>>> inner = outer() # get inner function +>>> inner() # prints variable foo without issue +bar +``` +**Official Documentation** +**1.** [Program structure, name binding and resolution](https://docs.python.org/3/reference/executionmodel.html#execution-model) +**2.** [`global` statement](https://docs.python.org/3/reference/simple_stmts.html#the-global-statement) +**3.** [`nonlocal` statement](https://docs.python.org/3/reference/simple_stmts.html#the-nonlocal-statement) \ No newline at end of file diff --git a/bot/resources/tags/seek.md b/bot/resources/tags/seek.md new file mode 100644 index 000000000..ada23fd00 --- /dev/null +++ b/bot/resources/tags/seek.md @@ -0,0 +1,22 @@ +**Seek** + +In the context of a [file object](https://docs.python.org/3/glossary.html#term-file-object), the `seek` function changes the stream position to a given byte offset, with an optional argument of where to offset from. While you can find the official documentation [here](https://docs.python.org/3/library/io.html#io.IOBase.seek), it can be unclear how to actually use this feature, so keep reading to see examples on how to use it. + +File named `example`: +``` +foobar +spam eggs +``` +Open file for reading in byte mode: +```py +f = open('example', 'rb') +``` +Note that stream positions start from 0 in much the same way that the index for a list does. If we do `f.seek(3, 0)`, our stream position will move 3 bytes forward relative to the **beginning** of the stream. Now if we then did `f.read(1)` to read a single byte from where we are in the stream, it would return the string `'b'` from the 'b' in 'foobar'. Notice that the 'b' is the 4th character. Also note that after we did `f.read(1)`, we moved the stream position again 1 byte forward relative to the **current** position in the stream. So the stream position is now currently at position 4. + +Now lets do `f.seek(4, 1)`. This will move our stream position 4 bytes forward relative to our **current** position in the stream. Now if we did `f.read(1)`, it would return the string `'p'` from the 'p' in 'spam' on the next line. Note this time that the character at position 6 is the newline character `'\n'`. + +Finally, lets do `f.seek(-4, 2)`, moving our stream position *backwards* 4 bytes relative to the **end** of the stream. Now if we did `f.read()` to read everything after our position in the file, it would return the string `'eggs'` and also move our stream position to the end of the file. + +**Note** +• For the second argument in `seek()`, use `os.SEEK_SET`, `os.SEEK_CUR`, and `os.SEEK_END` in place of 0, 1, and 2 respectively. +• `os.SEEK_CUR` is only usable when the file is in byte mode. \ No newline at end of file diff --git a/bot/resources/tags/self.md b/bot/resources/tags/self.md new file mode 100644 index 000000000..a9cd5e9df --- /dev/null +++ b/bot/resources/tags/self.md @@ -0,0 +1,25 @@ +**Class instance** + +When calling a method from a class instance (ie. `instance.method()`), the instance itself will automatically be passed as the first argument implicitly. By convention, we call this `self`, but it could technically be called any valid variable name. + +```py +class Foo: + def bar(self): + print('bar') + + def spam(self, eggs): + print(eggs) + +foo = Foo() +``` + +If we call `foo.bar()`, it is equivalent to doing `Foo.bar(foo)`. Our instance `foo` is passed for us to the `bar` function, so while we initially gave zero arguments, it is actually called with one. + +Similarly if we call `foo.spam('ham')`, it is equivalent to +doing `Foo.spam(foo, 'ham')`. + +**Why is this useful?** + +Methods do not inherently have access to attributes defined in the class. In order for any one method to be able to access other methods or variables defined in the class, it must have access to the instance. + +Consider if outside the class, we tried to do this: `spam(foo, 'ham')`. This would give an error, because we don't have access to the `spam` method directly, we have to call it by doing `foo.spam('ham')`. This is also the case inside of the class. If we wanted to call the `bar` method inside the `spam` method, we'd have to do `self.bar()`, just doing `bar()` would give an error. \ No newline at end of file diff --git a/bot/resources/tags/star-imports.md b/bot/resources/tags/star-imports.md new file mode 100644 index 000000000..4c7e0199c --- /dev/null +++ b/bot/resources/tags/star-imports.md @@ -0,0 +1,48 @@ +**Star / Wildcard imports** + +Wildcard imports are import statements in the form `from import *`. What imports like these do is that they import everything **[1]** from the module into the current module's namespace **[2]**. This allows you to use names defined in the imported module without prefixing the module's name. + +Example: +```python +>>> from math import * +>>> sin(pi / 2) +1.0 +``` +**This is discouraged, for various reasons:** + +Example: +```python +>>> from custom_sin import sin +>>> from math import * +>>> sin(pi / 2) # uses sin from math rather than your custom sin +``` + +• Potential namespace collision. Names defined from a previous import might get shadowed by a wildcard import. + +• Causes ambiguity. From the example, it is unclear which `sin` function is actually being used. From the Zen of Python **[3]**: `Explicit is better than implicit.` + +• Makes import order significant, which they shouldn't. Certain IDE's `sort import` functionality may end up breaking code due to namespace collision. + +**How should you import?** + +• Import the module under the module's namespace (Only import the name of the module, and names defined in the module can be used by prefixing the module's name) + +```python +>>> import math +>>> math.sin(math.pi / 2) +``` + +• Explicitly import certain names from the module + +```python +>>> from math import sin, pi +>>> sin(pi / 2) +``` + +Conclusion: Namespaces are one honking great idea -- let's do more of those! *[3]* + +**[1]** If the module defines the variable `__all__`, the names defined in `__all__` will get imported by the wildcard import, otherwise all the names in the module get imported (except for names with a leading underscore) + +**[2]** [Namespaces and scopes](https://www.programiz.com/python-programming/namespace) + +**[3]** [Zen of Python](https://www.python.org/dev/peps/pep-0020/) \ No newline at end of file diff --git a/bot/resources/tags/traceback.md b/bot/resources/tags/traceback.md new file mode 100644 index 000000000..74401abf0 --- /dev/null +++ b/bot/resources/tags/traceback.md @@ -0,0 +1,18 @@ +Please provide a full traceback to your exception in order for us to identify your issue. + +A full traceback could look like: +```py +Traceback (most recent call last): + File "tiny", line 3, in + do_something() + File "tiny", line 2, in do_something + a = 6 / 0 +ZeroDivisionError: integer division or modulo by zero +``` +The best way to read your traceback is bottom to top. + +• Identify the exception raised (e.g. ZeroDivisonError) +• Make note of the line number, and navigate there in your program. +• Try to understand why the error occurred. + +To read more about exceptions and errors, please refer to the [PyDis Wiki](https://pythondiscord.com/pages/asking-good-questions/#examining-tracebacks) or the [official Python tutorial.](https://docs.python.org/3.7/tutorial/errors.html) \ No newline at end of file diff --git a/bot/resources/tags/windows-path.md b/bot/resources/tags/windows-path.md new file mode 100644 index 000000000..d8723f06f --- /dev/null +++ b/bot/resources/tags/windows-path.md @@ -0,0 +1,30 @@ +**PATH on Windows** + +If you have installed Python but you forgot to check the *Add Python to PATH* option during the installation you may still be able to access your installation with ease. + +If you did not uncheck the option to install the Python launcher then you will find a `py` command on your system. If you want to be able to open your Python installation by running `python` then your best option is to re-install Python. + +Otherwise, you can access your install using the `py` command in Command Prompt. Where you may type something with the `python` command like: +``` +C:\Users\Username> python3 my_application_file.py +``` + +You can achieve the same result using the `py` command like this: +``` +C:\Users\Username> py -3 my_application_file.py +``` + +You can pass any options to the Python interpreter after you specify a version, for example, to install a Python module using `pip` you can run: +``` +C:\Users\Username> py -3 -m pip install numpy +``` + +You can also access different versions of Python using the version flag, like so: +``` +C:\Users\Username> py -3.7 +... Python 3.7 starts ... +C:\Users\Username> py -3.6 +... Python 3.6 stars ... +C:\Users\Username> py -2 +... Python 2 (any version installed) starts ... +``` \ No newline at end of file diff --git a/bot/resources/tags/with.md b/bot/resources/tags/with.md new file mode 100644 index 000000000..a79eb7dbb --- /dev/null +++ b/bot/resources/tags/with.md @@ -0,0 +1,8 @@ +The `with` keyword triggers a context manager. Context managers automatically set up and take down data connections, or any other kind of object that implements the magic methods `__enter__` and `__exit__`. +```py +with open("test.txt", "r") as file: + do_things(file) +``` +The above code automatically closes `file` when the `with` block exits, so you never have to manually do a `file.close()`. Most connection types, including file readers and database connections, support this. + +For more information, read [the official docs](https://docs.python.org/3/reference/compound_stmts.html#with), watch [Corey Schafer\'s context manager video](https://www.youtube.com/watch?v=-aKFBoZpiqA), or see [PEP 343](https://www.python.org/dev/peps/pep-0343/). \ No newline at end of file diff --git a/bot/resources/tags/xy-problem.md b/bot/resources/tags/xy-problem.md new file mode 100644 index 000000000..77700e7a0 --- /dev/null +++ b/bot/resources/tags/xy-problem.md @@ -0,0 +1,7 @@ +**xy-problem** + +Asking about your attempted solution rather than your actual problem. + +Often programmers will get distracted with a potential solution they've come up with, and will try asking for help getting it to work. However, it's possible this solution either wouldn't work as they expect, or there's a much better solution instead. + +For more information and examples: http://xyproblem.info/ \ No newline at end of file diff --git a/bot/resources/tags/ytdl.md b/bot/resources/tags/ytdl.md new file mode 100644 index 000000000..e1085d1af --- /dev/null +++ b/bot/resources/tags/ytdl.md @@ -0,0 +1,9 @@ +Per [PyDis' Rule 5](https://pythondiscord.com/pages/rules), we are unable to assist with questions related to youtube-dl, commonly used by Discord bots to stream audio, as its use violates YouTube's Terms of Service. + +For reference, this usage is covered by the following clauses in [YouTube's TOS](https://www.youtube.com/static?template=terms), as of 2018-05-25: +``` +4A: You agree not to distribute in any medium any part of the Service or the Content without YouTube's prior written authorization, unless YouTube makes available the means for such distribution through functionality offered by the Service (such as the Embeddable Player). +``` +``` +4C: You agree not to access Content through any technology or means other than the video playback pages of the Service itself, the Embeddable Player, or other explicitly authorized means YouTube may designate. +``` \ No newline at end of file diff --git a/bot/resources/tags/zen.md b/bot/resources/tags/zen.md new file mode 100644 index 000000000..3e132eed8 --- /dev/null +++ b/bot/resources/tags/zen.md @@ -0,0 +1,20 @@ + +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! diff --git a/bot/resources/tags/zip.md b/bot/resources/tags/zip.md new file mode 100644 index 000000000..9d2fe5ee3 --- /dev/null +++ b/bot/resources/tags/zip.md @@ -0,0 +1,12 @@ +The zip function allows you to iterate through multiple iterables simultaneously. It joins the iterables together, almost like a zipper, so that each new element is a tuple with one element from each iterable. + +```py +letters = 'abc' +numbers = [1, 2, 3] +# zip(letters, numbers) --> [('a', 1), ('b', 2), ('c', 3)] +for letter, number in zip(letters, numbers): + print(letter, number) +``` +The `zip()` iterator is exhausted after the length of the shortest iterable is exceeded. If you would like to retain the other values, consider using [itertools.zip_longest](https://docs.python.org/3/library/itertools.html#itertools.zip_longest). + +For more information on zip, please refer to the [official documentation](https://docs.python.org/3/library/functions.html#zip). \ No newline at end of file -- cgit v1.2.3 From f2563465396ab381d85f98b55b7a91b2ad00ed04 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Sat, 29 Feb 2020 14:50:45 +0530 Subject: added white spaces on statements before bullet points for proper rendering of points on github --- bot/resources/tags/args-kwargs.md | 10 +++++----- bot/resources/tags/ask.md | 8 ++++---- bot/resources/tags/codeblock.md | 8 ++++---- bot/resources/tags/decorators.md | 6 +++--- bot/resources/tags/foo.md | 4 ++-- bot/resources/tags/inline.md | 6 +++--- bot/resources/tags/mutable-default-args.md | 2 +- bot/resources/tags/names.md | 18 +++++++++--------- bot/resources/tags/off-topic.md | 8 ++++---- bot/resources/tags/open.md | 8 ++++---- bot/resources/tags/pathlib.md | 24 ++++++++++++------------ bot/resources/tags/positional-keyword.md | 8 ++++---- bot/resources/tags/quotes.md | 4 ++-- bot/resources/tags/return.md | 10 +++++----- bot/resources/tags/round.md | 10 +++++----- bot/resources/tags/scope.md | 6 +++--- bot/resources/tags/seek.md | 4 ++-- bot/resources/tags/traceback.md | 6 +++--- 18 files changed, 75 insertions(+), 75 deletions(-) diff --git a/bot/resources/tags/args-kwargs.md b/bot/resources/tags/args-kwargs.md index fb19d39fd..de883dea8 100644 --- a/bot/resources/tags/args-kwargs.md +++ b/bot/resources/tags/args-kwargs.md @@ -8,10 +8,10 @@ These special parameters allow functions to take arbitrary amounts of positional **Double asterisk** `**kwargs` will ingest an arbitrary amount of **keyword arguments**, and store it in a dictionary. There can be **no** additional parameters **after** `**kwargs` in the parameter list. -**Use cases** -• **Decorators** (see `!tags decorators`) -• **Inheritance** (overriding methods) -• **Future proofing** (in the case of the first two bullet points, if the parameters change, your code won't break) -• **Flexibility** (writing functions that behave like `dict()` or `print()`) +**Use cases** +• **Decorators** (see `!tags decorators`) +• **Inheritance** (overriding methods) +• **Future proofing** (in the case of the first two bullet points, if the parameters change, your code won't break) +• **Flexibility** (writing functions that behave like `dict()` or `print()`) *See* `!tags positional-keyword` *for information about positional and keyword arguments* \ No newline at end of file diff --git a/bot/resources/tags/ask.md b/bot/resources/tags/ask.md index 07f9bd84d..ed651e8c5 100644 --- a/bot/resources/tags/ask.md +++ b/bot/resources/tags/ask.md @@ -1,9 +1,9 @@ Asking good questions will yield a much higher chance of a quick response: -• Don't ask to ask your question, just go ahead and tell us your problem. -• Don't ask if anyone is knowledgeable in some area, filtering serves no purpose. -• Try to solve the problem on your own first, we're not going to write code for you. -• Show us the code you've tried and any errors or unexpected results it's giving. +• Don't ask to ask your question, just go ahead and tell us your problem. +• Don't ask if anyone is knowledgeable in some area, filtering serves no purpose. +• Try to solve the problem on your own first, we're not going to write code for you. +• Show us the code you've tried and any errors or unexpected results it's giving. • Be patient while we're helping you. You can find a much more detailed explanation [on our website](https://pythondiscord.com/pages/asking-good-questions/). \ No newline at end of file diff --git a/bot/resources/tags/codeblock.md b/bot/resources/tags/codeblock.md index 816bb8232..34db060ef 100644 --- a/bot/resources/tags/codeblock.md +++ b/bot/resources/tags/codeblock.md @@ -6,10 +6,10 @@ To do this, use the following method: print('Hello world!') \``` -Note: -• **These are backticks, not quotes.** Backticks can usually be found on the tilde key. -• You can also use py as the language instead of python -• The language must be on the first line next to the backticks with **no** space between them +Note: +• **These are backticks, not quotes.** Backticks can usually be found on the tilde key. +• You can also use py as the language instead of python +• The language must be on the first line next to the backticks with **no** space between them This will result in the following: ```py diff --git a/bot/resources/tags/decorators.md b/bot/resources/tags/decorators.md index 3ff1db16c..9b53af064 100644 --- a/bot/resources/tags/decorators.md +++ b/bot/resources/tags/decorators.md @@ -26,6 +26,6 @@ Time elapsed: 3.000307321548462 Finished! ``` -More information: -• [Corey Schafer's video on decorators](https://youtu.be/FsAPt_9Bf3U) -• [Real python article](https://realpython.com/primer-on-python-decorators/) \ No newline at end of file +More information: +• [Corey Schafer's video on decorators](https://youtu.be/FsAPt_9Bf3U) +• [Real python article](https://realpython.com/primer-on-python-decorators/) \ No newline at end of file diff --git a/bot/resources/tags/foo.md b/bot/resources/tags/foo.md index 58bc4b78f..2b5b659fd 100644 --- a/bot/resources/tags/foo.md +++ b/bot/resources/tags/foo.md @@ -5,6 +5,6 @@ A specific word or set of words identified as a placeholder used in programming. Common examples include `foobar`, `foo`, `bar`, `baz`, and `qux`. Python has its own metasyntactic variables, namely `spam`, `eggs`, and `bacon`. This is a reference to a [Monty Python](https://en.wikipedia.org/wiki/Monty_Python) sketch (the eponym of the language). -More information: -• [History of foobar](https://en.wikipedia.org/wiki/Foobar) +More information: +• [History of foobar](https://en.wikipedia.org/wiki/Foobar) • [Monty Python sketch](https://en.wikipedia.org/wiki/Spam_%28Monty_Python%29) \ No newline at end of file diff --git a/bot/resources/tags/inline.md b/bot/resources/tags/inline.md index 4670256bc..d0c9d1b5e 100644 --- a/bot/resources/tags/inline.md +++ b/bot/resources/tags/inline.md @@ -10,7 +10,7 @@ And results in the following: The `__init__` method customizes the newly created instance. -**Note:** -• These are **backticks** not quotes -• Avoid using them for multiple lines +**Note:** +• These are **backticks** not quotes +• Avoid using them for multiple lines • Useful for negating formatting you don't want \ No newline at end of file diff --git a/bot/resources/tags/mutable-default-args.md b/bot/resources/tags/mutable-default-args.md index 49f536b78..7b16e6b82 100644 --- a/bot/resources/tags/mutable-default-args.md +++ b/bot/resources/tags/mutable-default-args.md @@ -43,6 +43,6 @@ function is **called**: **Note**: • This behavior can be used intentionally to maintain state between -calls of a function (eg. when writing a caching function). +calls of a function (eg. when writing a caching function). • This behavior is not unique to mutable objects, all default arguments are evaulated only once when the function is defined. \ No newline at end of file diff --git a/bot/resources/tags/names.md b/bot/resources/tags/names.md index b7b914d53..462c550bc 100644 --- a/bot/resources/tags/names.md +++ b/bot/resources/tags/names.md @@ -22,16 +22,16 @@ y ━━━┛ x ━━ 2 y ━━ 1 ``` -**Names are created in multiple ways** -You might think that the only way to bind a name to an object is by using assignment, but that isn't the case. All of the following work exactly the same as assignment: -• `import` statements -• `class` and `def` -• `for` loop headers -• `as` keyword when used with `except`, `import`, and `with` -• formal parameters in function headers +**Names are created in multiple ways** +You might think that the only way to bind a name to an object is by using assignment, but that isn't the case. All of the following work exactly the same as assignment: +• `import` statements +• `class` and `def` +• `for` loop headers +• `as` keyword when used with `except`, `import`, and `with` +• formal parameters in function headers There is also `del` which has the purpose of *unbinding* a name. -**More info** -• Please watch [Ned Batchelder's talk](https://youtu.be/_AEJHKGk9ns) on names in python for a detailed explanation with examples +**More info** +• Please watch [Ned Batchelder's talk](https://youtu.be/_AEJHKGk9ns) on names in python for a detailed explanation with examples • [Official documentation](https://docs.python.org/3/reference/executionmodel.html#naming-and-binding) \ No newline at end of file diff --git a/bot/resources/tags/off-topic.md b/bot/resources/tags/off-topic.md index 8fa70bf6e..004adfa17 100644 --- a/bot/resources/tags/off-topic.md +++ b/bot/resources/tags/off-topic.md @@ -1,8 +1,8 @@ **Off-topic channels** -There are three off-topic channels: -• <#291284109232308226> -• <#463035241142026251> -• <#463035268514185226> +There are three off-topic channels: +• <#291284109232308226> +• <#463035241142026251> +• <#463035268514185226> Their names change randomly every 24 hours, but you can always find them under the `OFF-TOPIC/GENERAL` category in the channel list. \ No newline at end of file diff --git a/bot/resources/tags/open.md b/bot/resources/tags/open.md index 74150dbc7..1ba19dedd 100644 --- a/bot/resources/tags/open.md +++ b/bot/resources/tags/open.md @@ -2,10 +2,10 @@ The built-in function `open()` is one of several ways to open files on your computer. It accepts many different parameters, so this tag will only go over two of them (`file` and `mode`). For more extensive documentation on all these parameters, consult the [official documentation](https://docs.python.org/3/library/functions.html#open). The object returned from this function is a [file object or stream](https://docs.python.org/3/glossary.html#term-file-object), for which the full documentation can be found [here](https://docs.python.org/3/library/io.html#io.TextIOBase). -See also: -• `!tags with` for information on context managers -• `!tags pathlib` for an alternative way of opening files -• `!tags seek` for information on changing your position in a file +See also: +• `!tags with` for information on context managers +• `!tags pathlib` for an alternative way of opening files +• `!tags seek` for information on changing your position in a file **The `file` parameter** diff --git a/bot/resources/tags/pathlib.md b/bot/resources/tags/pathlib.md index 37913951d..468945cc5 100644 --- a/bot/resources/tags/pathlib.md +++ b/bot/resources/tags/pathlib.md @@ -4,18 +4,18 @@ Python 3 comes with a new module named `Pathlib`. Since Python 3.6, `pathlib.Pat **Feature spotlight**: -• Normalizes file paths for all platforms automatically -• Has glob-like utilites (eg. `Path.glob`, `Path.rglob`) for searching files -• Can read and write files, and close them automatically -• Convenient syntax, utilising the `/` operator (e.g. `Path('~') / 'Documents'`) -• Can easily pick out components of a path (eg. name, parent, stem, suffix, anchor) -• Supports method chaining -• Move and delete files -• And much more +• Normalizes file paths for all platforms automatically +• Has glob-like utilites (eg. `Path.glob`, `Path.rglob`) for searching files +• Can read and write files, and close them automatically +• Convenient syntax, utilising the `/` operator (e.g. `Path('~') / 'Documents'`) +• Can easily pick out components of a path (eg. name, parent, stem, suffix, anchor) +• Supports method chaining +• Move and delete files +• And much more **More Info**: -• [**Why you should use pathlib** - Trey Hunner](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) -• [**Answering concerns about pathlib** - Trey Hunner](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) -• [**Official Documentation**](https://docs.python.org/3/library/pathlib.html) -• [**PEP 519** - Adding a file system path protocol](https://www.python.org/dev/peps/pep-0519/) \ No newline at end of file +• [**Why you should use pathlib** - Trey Hunner](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) +• [**Answering concerns about pathlib** - Trey Hunner](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) +• [**Official Documentation**](https://docs.python.org/3/library/pathlib.html) +• [**PEP 519** - Adding a file system path protocol](https://www.python.org/dev/peps/pep-0519/) \ No newline at end of file diff --git a/bot/resources/tags/positional-keyword.md b/bot/resources/tags/positional-keyword.md index 3faec32ca..bc7f68ee0 100644 --- a/bot/resources/tags/positional-keyword.md +++ b/bot/resources/tags/positional-keyword.md @@ -32,7 +32,7 @@ The reverse is also true: 2 1 ``` -**More info** -• [Keyword only arguments](https://www.python.org/dev/peps/pep-3102/) -• [Positional only arguments](https://www.python.org/dev/peps/pep-0570/) -• `!tags param-arg` (Parameters vs. Arguments) \ No newline at end of file +**More info** +• [Keyword only arguments](https://www.python.org/dev/peps/pep-3102/) +• [Positional only arguments](https://www.python.org/dev/peps/pep-0570/) +• `!tags param-arg` (Parameters vs. Arguments) \ No newline at end of file diff --git a/bot/resources/tags/quotes.md b/bot/resources/tags/quotes.md index 609b6d2d2..bb6e2a009 100644 --- a/bot/resources/tags/quotes.md +++ b/bot/resources/tags/quotes.md @@ -15,6 +15,6 @@ Example: **Note:** If you need both single and double quotes inside your string, use the version that would result in the least amount of escapes. In the case of a tie, use the quotation you use the most. -**References:** -• [pep-8 on quotes](https://www.python.org/dev/peps/pep-0008/#string-quotes) +**References:** +• [pep-8 on quotes](https://www.python.org/dev/peps/pep-0008/#string-quotes) • [convention for triple quoted strings](https://www.python.org/dev/peps/pep-0257/) \ No newline at end of file diff --git a/bot/resources/tags/return.md b/bot/resources/tags/return.md index 7e0cdaa98..c944dddf2 100644 --- a/bot/resources/tags/return.md +++ b/bot/resources/tags/return.md @@ -28,8 +28,8 @@ None >>> print(x) None ``` -**Things to note** -• `print()` and `return` do **not** accomplish the same thing. `print()` will only print the value, it will not be accessible outside of the function afterwards. -• A function will return `None` if it ends without reaching an explicit `return` statement. -• When you want to print a value calculated in a function, instead of printing inside the function, it is often better to return the value and print the *function call* instead. -• [Official documentation for `return`](https://docs.python.org/3/reference/simple_stmts.html#the-return-statement) \ No newline at end of file +**Things to note** +• `print()` and `return` do **not** accomplish the same thing. `print()` will only print the value, it will not be accessible outside of the function afterwards. +• A function will return `None` if it ends without reaching an explicit `return` statement. +• When you want to print a value calculated in a function, instead of printing inside the function, it is often better to return the value and print the *function call* instead. +• [Official documentation for `return`](https://docs.python.org/3/reference/simple_stmts.html#the-return-statement) \ No newline at end of file diff --git a/bot/resources/tags/round.md b/bot/resources/tags/round.md index 3e33c8ff7..28a12469a 100644 --- a/bot/resources/tags/round.md +++ b/bot/resources/tags/round.md @@ -17,8 +17,8 @@ The round half up technique creates a slight bias towards the larger number. Wit It should be noted that round half to even distorts the distribution by increasing the probability of evens relative to odds, however this is considered less important than the bias explained above. -**References:** -• [Wikipedia article about rounding](https://en.wikipedia.org/wiki/Rounding#Round_half_to_even) -• [Documentation on `round` function](https://docs.python.org/3/library/functions.html#round) -• [`round` in what's new in python 3](https://docs.python.org/3/whatsnew/3.0.html#builtins) (4th bullet down) -• [How to force rounding technique](https://stackoverflow.com/a/10826537/4607272) \ No newline at end of file +**References:** +• [Wikipedia article about rounding](https://en.wikipedia.org/wiki/Rounding#Round_half_to_even) +• [Documentation on `round` function](https://docs.python.org/3/library/functions.html#round) +• [`round` in what's new in python 3](https://docs.python.org/3/whatsnew/3.0.html#builtins) (4th bullet down) +• [How to force rounding technique](https://stackoverflow.com/a/10826537/4607272) \ No newline at end of file diff --git a/bot/resources/tags/scope.md b/bot/resources/tags/scope.md index ff9d96637..c1eeb3b84 100644 --- a/bot/resources/tags/scope.md +++ b/bot/resources/tags/scope.md @@ -18,7 +18,7 @@ Alternatively if a variable is defined within a function block for example, it i >>> inner() # prints variable foo without issue bar ``` -**Official Documentation** -**1.** [Program structure, name binding and resolution](https://docs.python.org/3/reference/executionmodel.html#execution-model) -**2.** [`global` statement](https://docs.python.org/3/reference/simple_stmts.html#the-global-statement) +**Official Documentation** +**1.** [Program structure, name binding and resolution](https://docs.python.org/3/reference/executionmodel.html#execution-model) +**2.** [`global` statement](https://docs.python.org/3/reference/simple_stmts.html#the-global-statement) **3.** [`nonlocal` statement](https://docs.python.org/3/reference/simple_stmts.html#the-nonlocal-statement) \ No newline at end of file diff --git a/bot/resources/tags/seek.md b/bot/resources/tags/seek.md index ada23fd00..ff6569a0c 100644 --- a/bot/resources/tags/seek.md +++ b/bot/resources/tags/seek.md @@ -17,6 +17,6 @@ Now lets do `f.seek(4, 1)`. This will move our stream position 4 bytes forward r Finally, lets do `f.seek(-4, 2)`, moving our stream position *backwards* 4 bytes relative to the **end** of the stream. Now if we did `f.read()` to read everything after our position in the file, it would return the string `'eggs'` and also move our stream position to the end of the file. -**Note** -• For the second argument in `seek()`, use `os.SEEK_SET`, `os.SEEK_CUR`, and `os.SEEK_END` in place of 0, 1, and 2 respectively. +**Note** +• For the second argument in `seek()`, use `os.SEEK_SET`, `os.SEEK_CUR`, and `os.SEEK_END` in place of 0, 1, and 2 respectively. • `os.SEEK_CUR` is only usable when the file is in byte mode. \ No newline at end of file diff --git a/bot/resources/tags/traceback.md b/bot/resources/tags/traceback.md index 74401abf0..678ba1991 100644 --- a/bot/resources/tags/traceback.md +++ b/bot/resources/tags/traceback.md @@ -11,8 +11,8 @@ ZeroDivisionError: integer division or modulo by zero ``` The best way to read your traceback is bottom to top. -• Identify the exception raised (e.g. ZeroDivisonError) -• Make note of the line number, and navigate there in your program. -• Try to understand why the error occurred. +• Identify the exception raised (e.g. ZeroDivisonError) +• Make note of the line number, and navigate there in your program. +• Try to understand why the error occurred. To read more about exceptions and errors, please refer to the [PyDis Wiki](https://pythondiscord.com/pages/asking-good-questions/#examining-tracebacks) or the [official Python tutorial.](https://docs.python.org/3.7/tutorial/errors.html) \ No newline at end of file -- cgit v1.2.3 From fe31808089aa01c9e495d16d5c0cdbc4640a5ded Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Sat, 29 Feb 2020 15:19:37 +0530 Subject: Re-corrected the lines which I had changed by mistake --- bot/cogs/tags.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 0e959b45f..b62289e38 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -16,7 +16,8 @@ from bot.pagination import LinePaginator log = logging.getLogger(__name__) TEST_CHANNELS = ( - Channels.bot_commands, + Channels.devtest, + Channels.bot, Channels.helpers ) -- cgit v1.2.3 From 1b568681575d70d5b8dc5e8449e71a928896076c Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Sat, 29 Feb 2020 21:48:50 +0530 Subject: Caching all the tags when the bot has loaded(caching only once) insted of caching it after the tags command is used. --- bot/cogs/tags.py | 39 ++++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index b62289e38..3cab8c11f 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -31,27 +31,27 @@ class Tags(Cog): self.bot = bot self.tag_cooldowns = {} self._cache = {} - self._last_fetch: float = 0.0 - async def _get_tags(self, is_forced: bool = False) -> None: + @Cog.listener() + async def on_ready(self) -> None: + """Runs the code before the bot has connected.""" + await self.get_tags() + + async def get_tags(self) -> None: """Get all tags.""" - # refresh only when there's a more than 5m gap from last call. - time_now: float = time.time() - if is_forced or not self._last_fetch or time_now - self._last_fetch > 5 * 60: - tag_files = os.listdir("bot/resources/tags") - for file in tag_files: - p = Path("bot", "resources", "tags", file) - tag_title = os.path.splitext(file)[0].lower() - with p.open() as f: - tag = { - "title": tag_title, - "embed": { - "description": f.read() - } + # Save all tags in memory. + tag_files = os.listdir("bot/resources/tags") + for file in tag_files: + p = Path("bot", "resources", "tags", file) + tag_title = os.path.splitext(file)[0].lower() + with p.open() as f: + tag = { + "title": tag_title, + "embed": { + "description": f.read() } - self._cache[tag_title] = tag - - self._last_fetch = time_now + } + self._cache[tag_title] = tag @staticmethod def _fuzzy_search(search: str, target: str) -> float: @@ -92,7 +92,6 @@ class Tags(Cog): async def _get_tag(self, tag_name: str) -> list: """Get a specific tag.""" - await self._get_tags() found = [self._cache.get(tag_name.lower(), None)] if not found[0]: return self._get_suggestions(tag_name) @@ -133,8 +132,6 @@ class Tags(Cog): ) return - await self._get_tags() - if tag_name is not None: founds = await self._get_tag(tag_name) -- cgit v1.2.3 From ded89e843980902e03e60a3a44997b91826300de Mon Sep 17 00:00:00 2001 From: "Karlis. S" Date: Sat, 29 Feb 2020 20:23:03 +0200 Subject: !roles Command: Added pagination (LinePaginator), moved roles amount to title (was before in footer). --- bot/cogs/information.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 49beca15b..4dd4a7e75 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)} roles)", + colour=Colour.blurple() ) - embed.set_footer(text=f"Total roles: {len(roles)}") - await ctx.send(embed=embed) + await LinePaginator.paginate(role_list, ctx, embed) @with_role(*constants.MODERATION_ROLES) @command(name="role") -- cgit v1.2.3 From fc2224fc047fbbefdc17e3624ebc2854342c59c1 Mon Sep 17 00:00:00 2001 From: "Karlis. S" Date: Sun, 1 Mar 2020 09:35:34 +0200 Subject: !roles Command Test: Applied !roles command changes --- tests/bot/cogs/test_information.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 8443cfe71..bb4ebd9d0 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 roles)") 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\n") def test_role_info_command(self): """Tests the `role info` command.""" -- cgit v1.2.3 From 515075fb6a67e7fa7c299ec03a1e601d80da9cdd Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Wed, 26 Feb 2020 19:35:31 -0500 Subject: Add logging to antimalware cog & expand user feedback * Add generic handling for multi-file uploads * Log user, id, and blocked extensions * Provide the full list of attachment filenames as a logging extra * Provide feedback on all blacklisted file types uploaded --- bot/cogs/antimalware.py | 41 ++++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 9e9e81364..373619895 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -1,4 +1,5 @@ import logging +from os.path import splitext from discord import Embed, Message, NotFound from discord.ext.commands import Cog @@ -28,24 +29,30 @@ class AntiMalware(Cog): return embed = Embed() - for attachment in message.attachments: - filename = attachment.filename.lower() - if filename.endswith('.py'): - embed.description = ( - f"It looks like you tried to attach a Python file - please " - f"use a code-pasting service such as {URLs.site_schema}{URLs.site_paste}" - ) - break # Other detections irrelevant because we prioritize the .py message. - if not filename.endswith(tuple(AntiMalwareConfig.whitelist)): - whitelisted_types = ', '.join(AntiMalwareConfig.whitelist) - meta_channel = self.bot.get_channel(Channels.meta) - embed.description = ( - f"It looks like you tried to attach a file type that we " - f"do not allow. We currently allow the following file " - f"types: **{whitelisted_types}**. \n\n Feel free to ask " - f"in {meta_channel.mention} if you think this is a mistake." - ) + file_extensions = {splitext(message.filename.lower())[1] for message in message.attachments} + extensions_blocked = file_extensions - set(AntiMalwareConfig.whitelist) + if ".py" in extensions_blocked: + # Short-circuit on *.py files to provide a pastebin link + embed.description = ( + "It looks like you tried to attach a Python file - " + 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 = ( + f"It looks like you tried to attach file type(s) that we do not allow ({blocked_extensions_str}). " + f"We currently allow the following file types: **{whitelisted_types}**.\n\n" + f"Feel free to ask in {meta_channel.mention} if you think this is a mistake." + ) + if embed.description: + log.info( + f"User '{message.author}' ({message.author.id}) uploaded blacklisted file(s): {blocked_extensions_str}", + extra={"attachment_list": [attachment.filename for attachment in message.attachments]} + ) + await message.channel.send(f"Hey {message.author.mention}!", embed=embed) # Delete the offending message: -- cgit v1.2.3 From 1231f384ef9cf3aac6a4318b2ca4c465f2552aa8 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 2 Mar 2020 13:57:33 +0100 Subject: Add HushDurationConverter. --- bot/converters.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/bot/converters.py b/bot/converters.py index 1945e1da3..976376fce 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -262,6 +262,34 @@ class ISODateTime(Converter): return dt +class HushDurationConverter(Converter): + """Convert passed duration to `int` minutes or `None`.""" + + MINUTES_RE = re.compile(r"(\d+)(?:M|m|$)") + + async def convert(self, ctx: Context, argument: str) -> t.Optional[int]: + """ + Convert `argument` to a duration that's max 15 minutes or None. + + If `"forever"` is passed, None is returned; otherwise an int of the extracted time. + Accepted formats are: + , + m, + M, + forever. + """ + if argument == "forever": + return None + match = self.MINUTES_RE.match(argument) + if not match: + raise BadArgument(f"{argument} is not a valid minutes duration.") + + duration = int(match.group(1)) + if duration > 15: + raise BadArgument("Duration must be below 15 minutes.") + return duration + + def proxy_user(user_id: str) -> discord.Object: """ Create a proxy user object from the given id. -- cgit v1.2.3 From 28bcbf334eb08dfcd35b898b7cb803338664ee61 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 2 Mar 2020 09:42:00 -0800 Subject: Add more pre-commit hooks * Remove trailing whitespaces * Specify error code for a noqa in the free command --- .pre-commit-config.yaml | 23 ++++++++++++++++++++--- CONTRIBUTING.md | 2 +- bot/cogs/free.py | 2 +- tests/README.md | 10 +++++----- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 860357868..4bb5e7e1c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,10 +1,27 @@ +exclude: ^\.cache/|\.venv/|\.git/|htmlcov/|logs/ repos: -- repo: local + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.5.0 hooks: - - id: flake8 + - id: check-merge-conflict + - id: check-toml + - id: check-yaml + args: [--unsafe] # Required due to custom constructors (e.g. !ENV) + - id: end-of-file-fixer + - id: mixed-line-ending + args: [--fix=lf] + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.5.1 + hooks: + - id: python-check-blanket-noqa + - repo: local + hooks: + - id: flake8 name: Flake8 description: This hook runs flake8 within our project's pipenv environment. entry: pipenv run lint language: python types: [python] - require_serial: true \ No newline at end of file + require_serial: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 39f76c7b4..61d11f844 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,7 +43,7 @@ To provide a standalone development environment for this project, docker compose When pulling down changes from GitHub, remember to sync your environment using `pipenv sync --dev` to ensure you're using the most up-to-date versions the project's dependencies. ### Type Hinting -[PEP 484](https://www.python.org/dev/peps/pep-0484/) formally specifies type hints for Python functions, added to the Python Standard Library in version 3.5. Type hints are recognized by most modern code editing tools and provide useful insight into both the input and output types of a function, preventing the user from having to go through the codebase to determine these types. +[PEP 484](https://www.python.org/dev/peps/pep-0484/) formally specifies type hints for Python functions, added to the Python Standard Library in version 3.5. Type hints are recognized by most modern code editing tools and provide useful insight into both the input and output types of a function, preventing the user from having to go through the codebase to determine these types. For example: diff --git a/bot/cogs/free.py b/bot/cogs/free.py index 02c02d067..33b55e79a 100644 --- a/bot/cogs/free.py +++ b/bot/cogs/free.py @@ -55,7 +55,7 @@ class Free(Cog): msg = messages[seek - 1] # Otherwise get last message else: - msg = await channel.history(limit=1).next() # noqa (False positive) + msg = await channel.history(limit=1).next() # noqa: B305 inactive = (datetime.utcnow() - msg.created_at).seconds if inactive > TIMEOUT: diff --git a/tests/README.md b/tests/README.md index be78821bf..4f62edd68 100644 --- a/tests/README.md +++ b/tests/README.md @@ -83,7 +83,7 @@ TagContentConverter should return correct values for valid input. As we are trying to test our "units" of code independently, we want to make sure that we do not rely objects and data generated by "external" code. If we we did, then we wouldn't know if the failure we're observing was caused by the code we are actually trying to test or something external to it. -However, the features that we are trying to test often depend on those objects generated by external pieces of code. It would be difficult to test a bot command without having access to a `Context` instance. Fortunately, there's a solution for that: we use fake objects that act like the true object. We call these fake objects "mocks". +However, the features that we are trying to test often depend on those objects generated by external pieces of code. It would be difficult to test a bot command without having access to a `Context` instance. Fortunately, there's a solution for that: we use fake objects that act like the true object. We call these fake objects "mocks". To create these mock object, we mainly use the [`unittest.mock`](https://docs.python.org/3/library/unittest.mock.html) module. In addition, we have also defined a couple of specialized mock objects that mock specific `discord.py` types (see the section on the below.). @@ -114,13 +114,13 @@ class BotCogTests(unittest.TestCase): ### Mocking coroutines -By default, the `unittest.mock.Mock` and `unittest.mock.MagicMock` classes cannot mock coroutines, since the `__call__` method they provide is synchronous. In anticipation of the `AsyncMock` that will be [introduced in Python 3.8](https://docs.python.org/3.9/whatsnew/3.8.html#unittest), we have added an `AsyncMock` helper to [`helpers.py`](/tests/helpers.py). Do note that this drop-in replacement only implements an asynchronous `__call__` method, not the additional assertions that will come with the new `AsyncMock` type in Python 3.8. +By default, the `unittest.mock.Mock` and `unittest.mock.MagicMock` classes cannot mock coroutines, since the `__call__` method they provide is synchronous. In anticipation of the `AsyncMock` that will be [introduced in Python 3.8](https://docs.python.org/3.9/whatsnew/3.8.html#unittest), we have added an `AsyncMock` helper to [`helpers.py`](/tests/helpers.py). Do note that this drop-in replacement only implements an asynchronous `__call__` method, not the additional assertions that will come with the new `AsyncMock` type in Python 3.8. ### Special mocks for some `discord.py` types To quote Ned Batchelder, Mock objects are "automatic chameleons". This means that they will happily allow the access to any attribute or method and provide a mocked value in return. One downside to this is that if the code you are testing gets the name of the attribute wrong, your mock object will not complain and the test may still pass. -In order to avoid that, we have defined a number of Mock types in [`helpers.py`](/tests/helpers.py) that follow the specifications of the actual Discord types they are mocking. This means that trying to access an attribute or method on a mocked object that does not exist on the equivalent `discord.py` object will result in an `AttributeError`. In addition, these mocks have some sensible defaults and **pass `isinstance` checks for the types they are mocking**. +In order to avoid that, we have defined a number of Mock types in [`helpers.py`](/tests/helpers.py) that follow the specifications of the actual Discord types they are mocking. This means that trying to access an attribute or method on a mocked object that does not exist on the equivalent `discord.py` object will result in an `AttributeError`. In addition, these mocks have some sensible defaults and **pass `isinstance` checks for the types they are mocking**. These special mocks are added when they are needed, so if you think it would be sensible to add another one, feel free to propose one in your PR. @@ -144,7 +144,7 @@ Finally, there are some considerations to make when writing tests, both for writ ### Test coverage is a starting point -Having test coverage is a good starting point for unit testing: If a part of your code was not covered by a test, we know that we have not tested it properly. The reverse is unfortunately not true: Even if the code we are testing has 100% branch coverage, it does not mean it's fully tested or guaranteed to work. +Having test coverage is a good starting point for unit testing: If a part of your code was not covered by a test, we know that we have not tested it properly. The reverse is unfortunately not true: Even if the code we are testing has 100% branch coverage, it does not mean it's fully tested or guaranteed to work. One problem is that 100% branch coverage may be misleading if we haven't tested our code against all the realistic input it may get in production. For instance, take a look at the following `member_information` function and the test we've written for it: @@ -169,7 +169,7 @@ class FunctionsTests(unittest.TestCase): If you were to run this test, not only would the function pass the test, `coverage.py` will also tell us that the test provides 100% branch coverage for the function. Can you spot the bug the test suite did not catch? -The problem here is that we have only tested our function with a member object that had `None` for the `member.joined` attribute. This means that `member.joined.stfptime("%d-%m-%Y")` was never executed during our test, leading to us missing the spelling mistake in `stfptime` (it should be `strftime`). +The problem here is that we have only tested our function with a member object that had `None` for the `member.joined` attribute. This means that `member.joined.stfptime("%d-%m-%Y")` was never executed during our test, leading to us missing the spelling mistake in `stfptime` (it should be `strftime`). Adding another test would not increase the test coverage we have, but it does ensure that we'll notice that this function can fail with realistic data: -- cgit v1.2.3 From be6738983e5150ca24c20cbd7b482002ab9d69e6 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 2 Mar 2020 23:24:05 +0100 Subject: Add Silence cog. FirstHash is used for handling channels in `loop_alert_channels` set as tuples without considering other elements. --- bot/cogs/moderation/__init__.py | 2 + bot/cogs/moderation/silence.py | 141 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 bot/cogs/moderation/silence.py diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py index 5243cb92d..0349fe4b1 100644 --- a/bot/cogs/moderation/__init__.py +++ b/bot/cogs/moderation/__init__.py @@ -2,6 +2,7 @@ from bot.bot import Bot from .infractions import Infractions from .management import ModManagement from .modlog import ModLog +from .silence import Silence from .superstarify import Superstarify @@ -10,4 +11,5 @@ def setup(bot: Bot) -> None: bot.add_cog(Infractions(bot)) bot.add_cog(ModLog(bot)) bot.add_cog(ModManagement(bot)) + bot.add_cog(Silence(bot)) bot.add_cog(Superstarify(bot)) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py new file mode 100644 index 000000000..f37196744 --- /dev/null +++ b/bot/cogs/moderation/silence.py @@ -0,0 +1,141 @@ +import asyncio +import logging +from contextlib import suppress +from typing import Optional + +from discord import PermissionOverwrite, TextChannel +from discord.ext import commands, tasks +from discord.ext.commands import Context, TextChannelConverter + +from bot.bot import Bot +from bot.constants import Channels, Emojis, Guild, Roles +from bot.converters import HushDurationConverter + +log = logging.getLogger(__name__) + + +class FirstHash(tuple): + """Tuple with only first item used for hash and eq.""" + + def __new__(cls, *args): + """Construct tuple from `args`.""" + return super().__new__(cls, args) + + def __hash__(self): + return hash((self[0],)) + + def __eq__(self, other: "FirstHash"): + return self[0] == other[0] + + +class Silence(commands.Cog): + """Commands for stopping channel messages for `verified` role in a channel.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.loop_alert_channels = set() + self.bot.loop.create_task(self._get_server_values()) + + async def _get_server_values(self) -> None: + """Fetch required internal values after they're available.""" + await self.bot.wait_until_guild_available() + guild = self.bot.get_guild(Guild.id) + self._verified_role = guild.get_role(Roles.verified) + self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts) + self._mod_log_channel = self.bot.get_channel(Channels.mod_log) + + @commands.command(aliases=("hush",)) + async def silence( + self, + ctx: Context, + duration: HushDurationConverter = 10, + channel: TextChannelConverter = None + ) -> None: + """ + Silence `channel` for `duration` minutes or `"forever"`. + + If duration is forever, start a notifier loop that triggers every 15 minutes. + """ + channel = channel or ctx.channel + + if not await self._silence(channel, persistent=(duration is None), duration=duration): + await ctx.send(f"{Emojis.cross_mark} {channel.mention} is already silenced.") + return + if duration is None: + await ctx.send(f"{Emojis.check_mark} Channel {channel.mention} silenced indefinitely.") + return + + await ctx.send(f"{Emojis.check_mark} {channel.mention} silenced for {duration} minute(s).") + await asyncio.sleep(duration*60) + await self.unsilence(ctx, channel) + + @commands.command(aliases=("unhush",)) + async def unsilence(self, ctx: Context, channel: TextChannelConverter = None) -> None: + """ + Unsilence `channel`. + + Unsilence a previously silenced `channel` and remove it from indefinitely muted channels notice if applicable. + """ + channel = channel or ctx.channel + alert_channel = self._mod_log_channel if ctx.invoked_with == "hush" else ctx.channel + + if await self._unsilence(channel): + await alert_channel.send(f"{Emojis.check_mark} Unsilenced {channel.mention}.") + + async def _silence(self, channel: TextChannel, persistent: bool, duration: Optional[int]) -> bool: + """ + Silence `channel` for `self._verified_role`. + + If `persistent` is `True` add `channel` with current iteration of `self._notifier` + to `self.self.loop_alert_channels` and attempt to start notifier. + `duration` is only used for logging; if None is passed `persistent` should be True to not log None. + """ + if channel.overwrites_for(self._verified_role).send_messages is False: + log.debug(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.") + return False + await channel.set_permissions(self._verified_role, overwrite=PermissionOverwrite(send_messages=False)) + if persistent: + log.debug(f"Silenced #{channel} ({channel.id}) indefinitely.") + self.loop_alert_channels.add(FirstHash(channel, self._notifier.current_loop)) + with suppress(RuntimeError): + self._notifier.start() + return True + + log.debug(f"Silenced #{channel} ({channel.id}) for {duration} minute(s).") + return True + + async def _unsilence(self, channel: TextChannel) -> bool: + """ + Unsilence `channel`. + + Check if `channel` is silenced through a `PermissionOverwrite`, + if it is unsilence it, attempt to remove it from `self.loop_alert_channels` + and if `self.loop_alert_channels` are left empty, stop the `self._notifier` + """ + if channel.overwrites_for(self._verified_role).send_messages is False: + await channel.set_permissions(self._verified_role, overwrite=None) + log.debug(f"Unsilenced channel #{channel} ({channel.id}).") + + with suppress(KeyError): + self.loop_alert_channels.remove(FirstHash(channel)) + if not self.loop_alert_channels: + self._notifier.cancel() + return True + log.debug(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") + return False + + @tasks.loop() + async def _notifier(self) -> None: + """Post notice of permanently silenced channels to `mod_alerts` periodically.""" + # Wait for 15 minutes between notices with pause at start of loop. + await asyncio.sleep(15*60) + current_iter = self._notifier.current_loop+1 + channels_text = ', '.join( + f"{channel.mention} for {current_iter-start} min" + for channel, start in self.loop_alert_channels + ) + channels_log_text = ', '.join( + f'#{channel} ({channel.id})' for channel, _ in self.loop_alert_channels + ) + log.debug(f"Sending notice with channels: {channels_log_text}") + await self._mod_alerts_channel.send(f"<@&{Roles.moderators}> currently silenced channels: {channels_text}") -- cgit v1.2.3 From 1f8933f5551185aa60dc11807778c0bf7f78b0c3 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 2 Mar 2020 23:25:36 +0100 Subject: Add logging to loop start and loop end. --- bot/cogs/moderation/silence.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index f37196744..560a0a15c 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -139,3 +139,11 @@ class Silence(commands.Cog): ) log.debug(f"Sending notice with channels: {channels_log_text}") await self._mod_alerts_channel.send(f"<@&{Roles.moderators}> currently silenced channels: {channels_text}") + + @_notifier.before_loop + async def _log_notifier_start(self) -> None: + log.trace("Starting notifier loop.") + + @_notifier.after_loop + async def _log_notifier_end(self) -> None: + log.trace("Stopping notifier loop.") -- cgit v1.2.3 From cc322adb7486560481cee1e2e5bf511114631d8c Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 3 Mar 2020 08:50:21 -0800 Subject: Fix typo in comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Leon Sandøy --- bot/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/bot.py b/bot/bot.py index 54b092193..950ac6751 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -44,7 +44,7 @@ class Bot(commands.Bot): Will cause a DeprecationWarning if called outside a coroutine. """ - # Because discord.py recreates the HTTPClient session, may as well follow suite and recreate + # Because discord.py recreates the HTTPClient session, may as well follow suit and recreate # our own stuff here too. self._recreate() super().clear() -- cgit v1.2.3 From eddd515d864666b45f243ddc516981f10cedcda3 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Tue, 3 Mar 2020 21:18:40 -0500 Subject: Prevent exception if a watched user sends a DM to the bot The previous embed assumed that the messages would be sent on the server, where the channel would have a name and the message would have a jump URL. For a DM, neither of these are present and an exception will be raised when attempting to construct the embed for the webhook to send. --- bot/cogs/watchchannels/watchchannel.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 3667a80e8..479820444 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -9,7 +9,7 @@ from typing import Optional import dateutil.parser import discord -from discord import Color, Embed, HTTPException, Message, errors +from discord import Color, DMChannel, Embed, HTTPException, Message, errors from discord.ext.commands import Cog, Context from bot.api import ResponseCodeError @@ -273,7 +273,14 @@ class WatchChannel(metaclass=CogABCMeta): reason = self.watched_users[user_id]['reason'] - embed = Embed(description=f"{msg.author.mention} in [#{msg.channel.name}]({msg.jump_url})") + if isinstance(msg.channel, DMChannel): + # If a watched user DMs the bot there won't be a channel name or jump URL + # This could technically include a GroupChannel but bot's can't be in those + message_jump = "via DM" + else: + message_jump = f"in [#{msg.channel.name}]({msg.jump_url})" + + embed = Embed(description=f"{msg.author.mention} {message_jump}") embed.set_footer(text=f"Added {time_delta} by {actor} | Reason: {reason}") await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url) -- cgit v1.2.3 From 3b65766cf8fe095b91556efa49fabffefce5d49e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 2 Mar 2020 10:36:02 -0800 Subject: Use pre-commit in pipenv lint script --- .pre-commit-config.yaml | 2 +- Pipfile | 2 +- azure-pipelines.yml | 12 ++++++++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4bb5e7e1c..f369fb7d1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: - id: flake8 name: Flake8 description: This hook runs flake8 within our project's pipenv environment. - entry: pipenv run lint + entry: pipenv run flake8 language: python types: [python] require_serial: true diff --git a/Pipfile b/Pipfile index 64760f9dd..decc13e33 100644 --- a/Pipfile +++ b/Pipfile @@ -41,7 +41,7 @@ python_version = "3.8" [scripts] start = "python -m bot" -lint = "python -m flake8" +lint = "pre-commit run --all-files" precommit = "pre-commit install" build = "docker build -t pythondiscord/bot:latest -f Dockerfile ." push = "docker push pythondiscord/bot:latest" diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 35dea089a..902bfcd56 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -13,10 +13,12 @@ jobs: variables: PIP_CACHE_DIR: ".cache/pip" + PRE_COMMIT_HOME: $(Pipeline.Workspace)/pre-commit-cache steps: - task: UsePythonVersion@0 displayName: 'Set Python version' + name: PythonVersion inputs: versionSpec: '3.8.x' addToPath: true @@ -27,8 +29,14 @@ jobs: - script: pipenv install --dev --deploy --system displayName: 'Install project using pipenv' - - script: python -m flake8 - displayName: 'Run linter' + - task: Cache@2 + displayName: 'Restore pre-commit environment' + inputs: + key: pre-commit | .pre-commit-config.yaml | "$(PythonVersion.pythonLocation)" + path: $(PRE_COMMIT_HOME) + + - script: pre-commit run --all-files --show-diff-on-failure + 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 displayName: Run tests -- cgit v1.2.3 From f5b5298bbe77c52ae0d8b01b3c44fc22cdce35c3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 2 Mar 2020 19:33:13 -0800 Subject: CI: add a restore key for the pre-commit cache A cache for an outdated pre-commit environment may still be useful. It may be the case that only some hooks need to be updated rather than all. --- azure-pipelines.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 902bfcd56..fa85e6045 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -32,7 +32,9 @@ jobs: - task: Cache@2 displayName: 'Restore pre-commit environment' inputs: - key: pre-commit | .pre-commit-config.yaml | "$(PythonVersion.pythonLocation)" + key: pre-commit | "$(PythonVersion.pythonLocation)" | .pre-commit-config.yaml + restoreKeys: | + pre-commit | "$(PythonVersion.pythonLocation)" path: $(PRE_COMMIT_HOME) - script: pre-commit run --all-files --show-diff-on-failure -- cgit v1.2.3 From 93f29f8bfee77957770dab3dd9adc1dac62d0bb2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 3 Mar 2020 19:45:46 -0800 Subject: CI: mock the pipenv binary The mock gets used by the flake8 pre-commit hook, which invokes flake8 via `pipenv run flake8`. It's normally useful to use pipenv here cause it ensures flake8 is invoked within the context of the venv. However, in CI, there is no venv - dependencies are installed directly to the system site-packages. `pipenv run` does not work in such case because it tries to create a new venv if one doesn't exist (it doesn't consider the system interpreter to be a venv). This workaround (okay, it's a hack) creates an executable shell script which replaces the original pipenv binary. The shell script simply ignores the first argument (i.e. ignores `run` in `pipenv run`) and executes the rest of the arguments as a command. It essentially makes `pipenv run flake8` equivalent to just having ran `flake8`. When pre-commit executes pipenv, the aforementioned script is what will run. --- azure-pipelines.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index fa85e6045..280f11a36 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -29,6 +29,16 @@ jobs: - script: pipenv install --dev --deploy --system displayName: 'Install project using pipenv' + # Create an executable shell script which replaces the original pipenv binary. + # The shell script ignores the first argument and executes the rest of the args as a command. + # It makes the `pipenv run flake8` command in the pre-commit hook work by circumventing + # pipenv entirely, which is too dumb to know it should use the system interpreter rather than + # creating a new venv. + - script: | + printf '%s\n%s' '#!/bin/bash' '"${@:2}"' > $(PythonVersion.pythonLocation)/bin/pipenv \ + && chmod +x $(PythonVersion.pythonLocation)/bin/pipenv + displayName: 'Mock pipenv binary' + - task: Cache@2 displayName: 'Restore pre-commit environment' inputs: -- cgit v1.2.3 From da94ddbcdcd644a36c329fb7ec84d1c384b8ac58 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Tue, 3 Mar 2020 22:51:28 -0500 Subject: Add pep8-naming & relock --- Pipfile | 1 + Pipfile.lock | 41 +++++++++++++++++++++++++++-------------- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/Pipfile b/Pipfile index decc13e33..0dcee0e3d 100644 --- a/Pipfile +++ b/Pipfile @@ -31,6 +31,7 @@ flake8-import-order = "~=0.18" flake8-string-format = "~=0.2" flake8-tidy-imports = "~=4.0" flake8-todo = "~=0.7" +pep8-naming = "~=0.9" pre-commit = "~=2.1" safety = "~=1.8" unittest-xml-reporting = "~=3.0" diff --git a/Pipfile.lock b/Pipfile.lock index 9953aab40..348456f2c 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "fae6dcdb6a5ebf27e8ea5044f4ca2ab854774d17affb5fd64ac85f8d0ae71187" + "sha256": "b8b38e84230bdc37f8c8955e8dddc442183a2e23c4dfc6ed37c522644aecdeea" }, "pipfile-spec": 6, "requires": { @@ -18,11 +18,11 @@ "default": { "aio-pika": { "hashes": [ - "sha256:4199122a450dffd8303b7857a9d82657bf1487fe329e489520833b40fbe92406", - "sha256:fe85c7456e5c060bce4eb9cffab5b2c4d3c563cb72177977b3556c54c8e3aeb6" + "sha256:0332bc13abbd8923dac657b331716778c55ea0a32ac0951306ce85edafcc916c", + "sha256:39770d8bc7e9059e28622d599e2ac9ebc16a7198b33d1743c1a496ca3b0f8170" ], "index": "pypi", - "version": "==6.5.2" + "version": "==6.5.3" }, "aiodns": { "hashes": [ @@ -189,10 +189,10 @@ }, "humanfriendly": { "hashes": [ - "sha256:cbe04ecf964ccb951a578f396091f258448ca4b4b4c6d4b6194f48ef458fe991", - "sha256:e8e2e4524409e55d5c5cbbb4c555a0c0a9599d5e8f74d0ce1ac504ba51ad1cd2" + "sha256:2f79aaa2965c0fc3d79452e64ec2c7601d70d67e51ea2e99cb40afe3fe2824c5", + "sha256:6990c0af4b72f50ddf302900eb982edf199247e621e06d80d71b00b1a1574214" ], - "version": "==7.2" + "version": "==8.0" }, "idna": { "hashes": [ @@ -379,8 +379,7 @@ }, "pycparser": { "hashes": [ - "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3", - "sha256:fd64020e8a5e0369de455adf9f22795a90fdb74e6bb999e9a13fd26b54f533ef" + "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3" ], "version": "==2.19" }, @@ -663,8 +662,7 @@ }, "distlib": { "hashes": [ - "sha256:2e166e231a26b36d6dfe35a48c4464346620f8645ed0ace01ee31822b288de21", - "sha256:9b183fb98f4870e02d315d5d17baef14be74c339d827346cae544f5597698555" + "sha256:2e166e231a26b36d6dfe35a48c4464346620f8645ed0ace01ee31822b288de21" ], "version": "==0.3.0" }, @@ -707,11 +705,11 @@ }, "flake8-annotations": { "hashes": [ - "sha256:19a6637a5da1bb7ea7948483ca9e2b9e15b213e687e7bf5ff8c1bfc91c185006", - "sha256:bb033b72cdd3a2b0a530bbdf2081f12fbea7d70baeaaebb5899723a45f424b8e" + "sha256:a38b44d01abd480586a92a02a2b0a36231ec42dcc5e114de78fa5db016d8d3f9", + "sha256:d5b0e8704e4e7728b352fa1464e23539ff2341ba11cc153b536fa2cf921ee659" ], "index": "pypi", - "version": "==2.0.0" + "version": "==2.0.1" }, "flake8-bugbear": { "hashes": [ @@ -737,6 +735,13 @@ "index": "pypi", "version": "==0.18.1" }, + "flake8-polyfill": { + "hashes": [ + "sha256:12be6a34ee3ab795b19ca73505e7b55826d5f6ad7230d31b18e106400169b9e9", + "sha256:e44b087597f6da52ec6393a709e7108b2905317d0c0b744cdca6208e670d8eda" + ], + "version": "==1.0.2" + }, "flake8-string-format": { "hashes": [ "sha256:65f3da786a1461ef77fca3780b314edb2853c377f2e35069723348c8917deaa2", @@ -794,6 +799,14 @@ ], "version": "==20.1" }, + "pep8-naming": { + "hashes": [ + "sha256:45f330db8fcfb0fba57458c77385e288e7a3be1d01e8ea4268263ef677ceea5f", + "sha256:a33d38177056321a167decd6ba70b890856ba5025f0a8eca6a3eda607da93caf" + ], + "index": "pypi", + "version": "==0.9.1" + }, "pre-commit": { "hashes": [ "sha256:09ebe467f43ce24377f8c2f200fe3cd2570d328eb2ce0568c8e96ce19da45fa6", -- cgit v1.2.3 From aae928ebc06e7e7a6ed5b5b848464ce95e4ea9d8 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Tue, 3 Mar 2020 22:53:19 -0500 Subject: Remove CaseInsensitiveDict This was added by the now-removed Snake cog & is not used elsewhere on bot. --- bot/utils/__init__.py | 57 ------------------------------------------------- tests/bot/test_utils.py | 37 -------------------------------- 2 files changed, 94 deletions(-) delete mode 100644 tests/bot/test_utils.py diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 3e4b15ce4..9b32e515d 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,5 +1,4 @@ from abc import ABCMeta -from typing import Any, Hashable from discord.ext.commands import CogMeta @@ -8,59 +7,3 @@ class CogABCMeta(CogMeta, ABCMeta): """Metaclass for ABCs meant to be implemented as Cogs.""" pass - - -class CaseInsensitiveDict(dict): - """ - We found this class on StackOverflow. Thanks to m000 for writing it! - - https://stackoverflow.com/a/32888599/4022104 - """ - - @classmethod - def _k(cls, key: Hashable) -> Hashable: - """Return lowered key if a string-like is passed, otherwise pass key straight through.""" - return key.lower() if isinstance(key, str) else key - - def __init__(self, *args, **kwargs): - super(CaseInsensitiveDict, self).__init__(*args, **kwargs) - self._convert_keys() - - def __getitem__(self, key: Hashable) -> Any: - """Case insensitive __setitem__.""" - return super(CaseInsensitiveDict, self).__getitem__(self.__class__._k(key)) - - def __setitem__(self, key: Hashable, value: Any): - """Case insensitive __setitem__.""" - super(CaseInsensitiveDict, self).__setitem__(self.__class__._k(key), value) - - def __delitem__(self, key: Hashable) -> Any: - """Case insensitive __delitem__.""" - return super(CaseInsensitiveDict, self).__delitem__(self.__class__._k(key)) - - def __contains__(self, key: Hashable) -> bool: - """Case insensitive __contains__.""" - return super(CaseInsensitiveDict, self).__contains__(self.__class__._k(key)) - - def pop(self, key: Hashable, *args, **kwargs) -> Any: - """Case insensitive pop.""" - return super(CaseInsensitiveDict, self).pop(self.__class__._k(key), *args, **kwargs) - - def get(self, key: Hashable, *args, **kwargs) -> Any: - """Case insensitive get.""" - return super(CaseInsensitiveDict, self).get(self.__class__._k(key), *args, **kwargs) - - def setdefault(self, key: Hashable, *args, **kwargs) -> Any: - """Case insensitive setdefault.""" - return super(CaseInsensitiveDict, self).setdefault(self.__class__._k(key), *args, **kwargs) - - def update(self, E: Any = None, **F) -> None: - """Case insensitive update.""" - super(CaseInsensitiveDict, self).update(self.__class__(E)) - super(CaseInsensitiveDict, self).update(self.__class__(**F)) - - def _convert_keys(self) -> None: - """Helper method to lowercase all existing string-like keys.""" - for k in list(self.keys()): - v = super(CaseInsensitiveDict, self).pop(k) - self.__setitem__(k, v) diff --git a/tests/bot/test_utils.py b/tests/bot/test_utils.py deleted file mode 100644 index d7bcc3ba6..000000000 --- a/tests/bot/test_utils.py +++ /dev/null @@ -1,37 +0,0 @@ -import unittest - -from bot import utils - - -class CaseInsensitiveDictTests(unittest.TestCase): - """Tests for the `CaseInsensitiveDict` container.""" - - def test_case_insensitive_key_access(self): - """Tests case insensitive key access and storage.""" - instance = utils.CaseInsensitiveDict() - - key = 'LEMON' - value = 'trees' - - instance[key] = value - self.assertIn(key, instance) - self.assertEqual(instance.get(key), value) - self.assertEqual(instance.get(key.casefold()), value) - self.assertEqual(instance.pop(key.casefold()), value) - self.assertNotIn(key, instance) - self.assertNotIn(key.casefold(), instance) - - instance.setdefault(key, value) - del instance[key] - self.assertNotIn(key, instance) - - def test_initialization_from_kwargs(self): - """Tests creating the dictionary from keyword arguments.""" - instance = utils.CaseInsensitiveDict({'FOO': 'bar'}) - self.assertEqual(instance['foo'], 'bar') - - def test_update_from_other_mapping(self): - """Tests updating the dictionary from another mapping.""" - instance = utils.CaseInsensitiveDict() - instance.update({'FOO': 'bar'}) - self.assertEqual(instance['foo'], 'bar') -- cgit v1.2.3 From 2c85b2241bd8a1e7ca8290cd385cded97c54f9bb Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Tue, 3 Mar 2020 22:59:07 -0500 Subject: Update code for pep8-naming compliance --- tests/base.py | 4 ++-- tests/bot/cogs/sync/test_base.py | 2 +- tests/bot/cogs/test_snekbox.py | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/base.py b/tests/base.py index 42174e911..d99b9ac31 100644 --- a/tests/base.py +++ b/tests/base.py @@ -31,7 +31,7 @@ class LoggingTestsMixin: """ @contextmanager - def assertNotLogs(self, logger=None, level=None, msg=None): + def assertNotLogs(self, logger=None, level=None, msg=None): # noqa: N802 """ Asserts that no logs of `level` and higher were emitted by `logger`. @@ -81,7 +81,7 @@ class LoggingTestsMixin: class CommandTestCase(unittest.IsolatedAsyncioTestCase): """TestCase with additional assertions that are useful for testing Discord commands.""" - async def assertHasPermissionsCheck( + async def assertHasPermissionsCheck( # noqa: N802 self, cmd: commands.Command, permissions: Dict[str, bool], diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index fe0594efe..6ee9dfda6 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -84,7 +84,7 @@ class SyncerSendPromptTests(unittest.IsolatedAsyncioTestCase): method.assert_called_once_with(constants.Channels.dev_core) - async def test_send_prompt_returns_None_if_channel_fetch_fails(self): + async def test_send_prompt_returns_none_if_channel_fetch_fails(self): """None should be returned if there's an HTTPException when fetching the channel.""" self.bot.get_channel.return_value = None self.bot.fetch_channel.side_effect = discord.HTTPException(mock.MagicMock(), "test error!") diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 9cd7f0154..fd9468829 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -89,15 +89,15 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(actual, expected) @patch('bot.cogs.snekbox.Signals', side_effect=ValueError) - def test_get_results_message_invalid_signal(self, mock_Signals: Mock): + def test_get_results_message_invalid_signal(self, mock_signals: Mock): self.assertEqual( self.cog.get_results_message({'stdout': '', 'returncode': 127}), ('Your eval job has completed with return code 127', '') ) @patch('bot.cogs.snekbox.Signals') - def test_get_results_message_valid_signal(self, mock_Signals: Mock): - mock_Signals.return_value.name = 'SIGTEST' + def test_get_results_message_valid_signal(self, mock_signals: Mock): + mock_signals.return_value.name = 'SIGTEST' self.assertEqual( self.cog.get_results_message({'stdout': '', 'returncode': 127}), ('Your eval job has completed with return code 127 (SIGTEST)', '') -- cgit v1.2.3 From c12b8e8d84cf8d2ad373c334e5c517d717285e14 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 4 Mar 2020 08:37:35 +0100 Subject: Use raw strings for docstrings with forward slashes A few docstrings in `bot.cogs.extensions` have forward slashed in them to escape Markdown rendering when our help feature uses these docstring in a Discord message. However, the use of forward slashes with an invalid escape sequence in docstrings now raises a DeprecationWarning in Python: /home/sebastiaan/pydis/repositories/bot/bot/cogs/extensions.py:72: DeprecationWarning: invalid escape sequence \* PEP 257 (Docstring Conventions, https://www.python.org/dev/peps/pep-0257/) states that raw strings should be used for docstrings that use forward slashes, so I've added the `r`-prefix to the docstrings that use forward slashes. --- bot/cogs/extensions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index b312e1a1d..fb6cd9aa3 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -69,7 +69,7 @@ class Extensions(commands.Cog): @extensions_group.command(name="load", aliases=("l",)) async def load_command(self, ctx: Context, *extensions: Extension) -> None: - """ + r""" Load extensions given their fully qualified or unqualified names. If '\*' or '\*\*' is given as the name, all unloaded extensions will be loaded. @@ -86,7 +86,7 @@ class Extensions(commands.Cog): @extensions_group.command(name="unload", aliases=("ul",)) async def unload_command(self, ctx: Context, *extensions: Extension) -> None: - """ + r""" Unload currently loaded extensions given their fully qualified or unqualified names. If '\*' or '\*\*' is given as the name, all loaded extensions will be unloaded. @@ -109,7 +109,7 @@ class Extensions(commands.Cog): @extensions_group.command(name="reload", aliases=("r",)) async def reload_command(self, ctx: Context, *extensions: Extension) -> None: - """ + r""" Reload extensions given their fully qualified or unqualified names. If an extension fails to be reloaded, it will be rolled-back to the prior working state. -- cgit v1.2.3 From 1c7b2d8a212ee837adf5dedb617310fea3b45080 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Wed, 4 Mar 2020 23:30:37 +0530 Subject: Use "pathlib" instead of "os" module and context manager The pathlib module simplifies opening and reading files, hence the os module and the context manager are no longer used. --- bot/cogs/tags.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 1c6b6aa21..7b5e3ed3a 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -1,5 +1,4 @@ import logging -import os import re import time from pathlib import Path @@ -39,18 +38,17 @@ class Tags(Cog): async def get_tags(self) -> None: """Get all tags.""" # Save all tags in memory. - tag_files = os.listdir("bot/resources/tags") + tag_files = Path("bot", "resources", "tags").iterdir() for file in tag_files: - p = Path("bot", "resources", "tags", file) - tag_title = os.path.splitext(file)[0].lower() - with p.open() as f: - tag = { - "title": tag_title, - "embed": { - "description": f.read() - } + file_path = Path(file) + tag_title = file_path.stem + tag = { + "title": tag_title, + "embed": { + "description": file_path.read_text() } - self._cache[tag_title] = tag + } + self._cache[tag_title] = tag @staticmethod def _fuzzy_search(search: str, target: str) -> float: -- cgit v1.2.3 From 073fdf1c69a9e4a2f9967d43a3e9960e9388aa23 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Wed, 4 Mar 2020 23:43:45 +0530 Subject: Convert "get_tags()" and "_get_tag()" to sync functions "get_tags()" and "_get_tag()" functions need not be async as we are no longer doing any API call but instead reading from local files. --- bot/cogs/tags.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 7b5e3ed3a..9665aa04e 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -28,16 +28,12 @@ class Tags(Cog): def __init__(self, bot: Bot): self.bot = bot self.tag_cooldowns = {} - self._cache = {} + self._cache = self.get_tags() - @Cog.listener() - async def on_ready(self) -> None: - """Runs the code before the bot has connected.""" - await self.get_tags() - - async def get_tags(self) -> None: + def get_tags(self) -> dict: """Get all tags.""" # Save all tags in memory. + cache = {} tag_files = Path("bot", "resources", "tags").iterdir() for file in tag_files: file_path = Path(file) @@ -48,7 +44,8 @@ class Tags(Cog): "description": file_path.read_text() } } - self._cache[tag_title] = tag + cache[tag_title] = tag + return cache @staticmethod def _fuzzy_search(search: str, target: str) -> float: @@ -87,7 +84,7 @@ class Tags(Cog): return [] - async def _get_tag(self, tag_name: str) -> list: + def _get_tag(self, tag_name: str) -> list: """Get a specific tag.""" found = [self._cache.get(tag_name.lower(), None)] if not found[0]: @@ -130,7 +127,7 @@ class Tags(Cog): return if tag_name is not None: - founds = await self._get_tag(tag_name) + founds = self._get_tag(tag_name) if len(founds) == 1: tag = founds[0] -- cgit v1.2.3 From 564690f79a61944c25d62530caaae671f6afcb31 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Wed, 4 Mar 2020 17:31:11 -0500 Subject: Update tag files for new linting hooks --- bot/resources/tags/args-kwargs.md | 2 +- bot/resources/tags/ask.md | 2 +- bot/resources/tags/class.md | 4 ++-- bot/resources/tags/classmethod.md | 2 +- bot/resources/tags/codeblock.md | 2 +- bot/resources/tags/decorators.md | 6 +++--- bot/resources/tags/dictcomps.md | 2 +- bot/resources/tags/enumerate.md | 4 ++-- bot/resources/tags/except.md | 2 +- bot/resources/tags/exit().md | 2 +- bot/resources/tags/f-strings.md | 2 +- bot/resources/tags/foo.md | 2 +- bot/resources/tags/functions-are-objects.md | 2 +- bot/resources/tags/global.md | 2 +- bot/resources/tags/if-name-main.md | 2 +- bot/resources/tags/indent.md | 4 ++-- bot/resources/tags/inline.md | 2 +- bot/resources/tags/iterate-dict.md | 2 +- bot/resources/tags/listcomps.md | 2 +- bot/resources/tags/mutable-default-args.md | 6 +++--- bot/resources/tags/names.md | 2 +- bot/resources/tags/off-topic.md | 6 +++--- bot/resources/tags/open.md | 2 +- bot/resources/tags/or-gotcha.md | 2 +- bot/resources/tags/param-arg.md | 2 +- bot/resources/tags/paste.md | 2 +- bot/resources/tags/pathlib.md | 2 +- bot/resources/tags/pep8.md | 2 +- bot/resources/tags/positional-keyword.md | 4 ++-- bot/resources/tags/precedence.md | 2 +- bot/resources/tags/quotes.md | 2 +- bot/resources/tags/relative-path.md | 2 +- bot/resources/tags/repl.md | 2 +- bot/resources/tags/return.md | 6 +++--- bot/resources/tags/round.md | 2 +- bot/resources/tags/scope.md | 4 ++-- bot/resources/tags/seek.md | 2 +- bot/resources/tags/self.md | 2 +- bot/resources/tags/star-imports.md | 2 +- bot/resources/tags/traceback.md | 2 +- bot/resources/tags/windows-path.md | 4 ++-- bot/resources/tags/with.md | 2 +- bot/resources/tags/xy-problem.md | 2 +- bot/resources/tags/ytdl.md | 2 +- bot/resources/tags/zip.md | 2 +- 45 files changed, 59 insertions(+), 59 deletions(-) diff --git a/bot/resources/tags/args-kwargs.md b/bot/resources/tags/args-kwargs.md index de883dea8..b440a2346 100644 --- a/bot/resources/tags/args-kwargs.md +++ b/bot/resources/tags/args-kwargs.md @@ -14,4 +14,4 @@ These special parameters allow functions to take arbitrary amounts of positional • **Future proofing** (in the case of the first two bullet points, if the parameters change, your code won't break) • **Flexibility** (writing functions that behave like `dict()` or `print()`) -*See* `!tags positional-keyword` *for information about positional and keyword arguments* \ No newline at end of file +*See* `!tags positional-keyword` *for information about positional and keyword arguments* diff --git a/bot/resources/tags/ask.md b/bot/resources/tags/ask.md index ed651e8c5..e2c2a88f6 100644 --- a/bot/resources/tags/ask.md +++ b/bot/resources/tags/ask.md @@ -6,4 +6,4 @@ Asking good questions will yield a much higher chance of a quick response: • Show us the code you've tried and any errors or unexpected results it's giving. • Be patient while we're helping you. -You can find a much more detailed explanation [on our website](https://pythondiscord.com/pages/asking-good-questions/). \ No newline at end of file +You can find a much more detailed explanation [on our website](https://pythondiscord.com/pages/asking-good-questions/). diff --git a/bot/resources/tags/class.md b/bot/resources/tags/class.md index 74c36b9fa..4f73fc974 100644 --- a/bot/resources/tags/class.md +++ b/bot/resources/tags/class.md @@ -10,7 +10,7 @@ Here is an example class: class Foo: def __init__(self, somedata): self.my_attrib = somedata - + def show(self): print(self.my_attrib) ``` @@ -22,4 +22,4 @@ bar = Foo('data') bar.show() ``` -We can access any of `Foo`'s methods via `bar.my_method()`, and access any of `bar`s data via `bar.my_attribute`. \ No newline at end of file +We can access any of `Foo`'s methods via `bar.my_method()`, and access any of `bar`s data via `bar.my_attribute`. diff --git a/bot/resources/tags/classmethod.md b/bot/resources/tags/classmethod.md index 43c6d9909..a4e803093 100644 --- a/bot/resources/tags/classmethod.md +++ b/bot/resources/tags/classmethod.md @@ -17,4 +17,4 @@ alternative_bot = Bot.from_config(default_config) # but this still works, too regular_bot = Bot("tokenstring") ``` -This is just one of the many use cases of `@classmethod`. A more in-depth explanation can be found [here](https://stackoverflow.com/questions/12179271/meaning-of-classmethod-and-staticmethod-for-beginner#12179752). \ No newline at end of file +This is just one of the many use cases of `@classmethod`. A more in-depth explanation can be found [here](https://stackoverflow.com/questions/12179271/meaning-of-classmethod-and-staticmethod-for-beginner#12179752). diff --git a/bot/resources/tags/codeblock.md b/bot/resources/tags/codeblock.md index 34db060ef..a28ae397b 100644 --- a/bot/resources/tags/codeblock.md +++ b/bot/resources/tags/codeblock.md @@ -14,4 +14,4 @@ Note: This will result in the following: ```py print('Hello world!') -``` \ No newline at end of file +``` diff --git a/bot/resources/tags/decorators.md b/bot/resources/tags/decorators.md index 9b53af064..39c943f0a 100644 --- a/bot/resources/tags/decorators.md +++ b/bot/resources/tags/decorators.md @@ -12,12 +12,12 @@ Consider the following example of a timer decorator: ... print('Time elapsed:', time.time() - start) ... return result ... return inner -... +... >>> @timer ... def slow(delay=1): ... time.sleep(delay) ... return 'Finished!' -... +... >>> print(slow()) Time elapsed: 1.0011568069458008 Finished! @@ -28,4 +28,4 @@ Finished! More information: • [Corey Schafer's video on decorators](https://youtu.be/FsAPt_9Bf3U) -• [Real python article](https://realpython.com/primer-on-python-decorators/) \ No newline at end of file +• [Real python article](https://realpython.com/primer-on-python-decorators/) diff --git a/bot/resources/tags/dictcomps.md b/bot/resources/tags/dictcomps.md index ddefa1299..11867d77b 100644 --- a/bot/resources/tags/dictcomps.md +++ b/bot/resources/tags/dictcomps.md @@ -17,4 +17,4 @@ They are also very useful for inverting the key value pairs of a dictionary that Also like list comprehensions, you can add a conditional to it in order to filter out items you don't want. -For more information and examples, check [PEP 274](https://www.python.org/dev/peps/pep-0274/) \ No newline at end of file +For more information and examples, check [PEP 274](https://www.python.org/dev/peps/pep-0274/) diff --git a/bot/resources/tags/enumerate.md b/bot/resources/tags/enumerate.md index 610843cf4..dd984af52 100644 --- a/bot/resources/tags/enumerate.md +++ b/bot/resources/tags/enumerate.md @@ -4,10 +4,10 @@ index = 0 for item in my_list: print(f"{index}: {item}") index += 1 -``` +``` into beautiful, _pythonic_ code: ```py for index, item in enumerate(my_list): print(f"{index}: {item}") ``` -For more information, check out [the official docs](https://docs.python.org/3/library/functions.html#enumerate), or [PEP 279](https://www.python.org/dev/peps/pep-0279/). \ No newline at end of file +For more information, check out [the official docs](https://docs.python.org/3/library/functions.html#enumerate), or [PEP 279](https://www.python.org/dev/peps/pep-0279/). diff --git a/bot/resources/tags/except.md b/bot/resources/tags/except.md index 66dce13ab..8f0abf156 100644 --- a/bot/resources/tags/except.md +++ b/bot/resources/tags/except.md @@ -14,4 +14,4 @@ try: except: print("An exception was raised, but we have no idea if it was a ValueError or an IndexError.") ``` -For more information about exception handling, see [the official Python docs](https://docs.python.org/3/tutorial/errors.html), or watch [Corey Schafer's video on exception handling](https://www.youtube.com/watch?v=NIWwJbo-9_8). \ No newline at end of file +For more information about exception handling, see [the official Python docs](https://docs.python.org/3/tutorial/errors.html), or watch [Corey Schafer's video on exception handling](https://www.youtube.com/watch?v=NIWwJbo-9_8). diff --git a/bot/resources/tags/exit().md b/bot/resources/tags/exit().md index 89f83f7e0..27da9f866 100644 --- a/bot/resources/tags/exit().md +++ b/bot/resources/tags/exit().md @@ -5,4 +5,4 @@ If you want to exit your code programmatically, you might think to use the funct You should use either [`SystemExit`](https://docs.python.org/3/library/exceptions.html#SystemExit) or [`sys.exit()`](https://docs.python.org/3/library/sys.html#sys.exit) instead. There's not much practical difference between these two other than having to `import sys` for the latter. Both take an optional argument to provide an exit status. -[Official documentation](https://docs.python.org/3/library/constants.html#exit) with the warning not to use `exit()` or `quit()` in source code. \ No newline at end of file +[Official documentation](https://docs.python.org/3/library/constants.html#exit) with the warning not to use `exit()` or `quit()` in source code. diff --git a/bot/resources/tags/f-strings.md b/bot/resources/tags/f-strings.md index 966fe6080..69bc82487 100644 --- a/bot/resources/tags/f-strings.md +++ b/bot/resources/tags/f-strings.md @@ -14,4 +14,4 @@ print("{0} are some of the largest snakes in the world".format(snake)) # Or keyword arguments print("{family} are some of the largest snakes in the world".format(family=snake)) -``` \ No newline at end of file +``` diff --git a/bot/resources/tags/foo.md b/bot/resources/tags/foo.md index 2b5b659fd..98529bfc0 100644 --- a/bot/resources/tags/foo.md +++ b/bot/resources/tags/foo.md @@ -7,4 +7,4 @@ Python has its own metasyntactic variables, namely `spam`, `eggs`, and `bacon`. More information: • [History of foobar](https://en.wikipedia.org/wiki/Foobar) -• [Monty Python sketch](https://en.wikipedia.org/wiki/Spam_%28Monty_Python%29) \ No newline at end of file +• [Monty Python sketch](https://en.wikipedia.org/wiki/Spam_%28Monty_Python%29) diff --git a/bot/resources/tags/functions-are-objects.md b/bot/resources/tags/functions-are-objects.md index d10e6b73e..01af7a721 100644 --- a/bot/resources/tags/functions-are-objects.md +++ b/bot/resources/tags/functions-are-objects.md @@ -36,4 +36,4 @@ class C: # open function is passed # to the staticmethod class -``` \ No newline at end of file +``` diff --git a/bot/resources/tags/global.md b/bot/resources/tags/global.md index fc60f9177..64c316b62 100644 --- a/bot/resources/tags/global.md +++ b/bot/resources/tags/global.md @@ -13,4 +13,4 @@ def update_score(score, roll): return score + roll score = update_score(score, roll) ``` -For in-depth explanations on why global variables are bad news in a variety of situations, see [this Stack Overflow answer](https://stackoverflow.com/questions/19158339/why-are-global-variables-evil/19158418#19158418). \ No newline at end of file +For in-depth explanations on why global variables are bad news in a variety of situations, see [this Stack Overflow answer](https://stackoverflow.com/questions/19158339/why-are-global-variables-evil/19158418#19158418). diff --git a/bot/resources/tags/if-name-main.md b/bot/resources/tags/if-name-main.md index d44f0086d..9d88bb897 100644 --- a/bot/resources/tags/if-name-main.md +++ b/bot/resources/tags/if-name-main.md @@ -23,4 +23,4 @@ If you run this module named `bar.py`, it will execute the code in `foo.py`. Fir • Your module is a library, but also has a special case where it can be run directly • Your module is a library and you want to safeguard it against people running it directly (like what `pip` does) -• Your module is the main program, but has unit tests and the testing framework works by importing your module, and you want to avoid having your main code run during the test \ No newline at end of file +• Your module is the main program, but has unit tests and the testing framework works by importing your module, and you want to avoid having your main code run during the test diff --git a/bot/resources/tags/indent.md b/bot/resources/tags/indent.md index 5b36a4818..dec8407b0 100644 --- a/bot/resources/tags/indent.md +++ b/bot/resources/tags/indent.md @@ -12,7 +12,7 @@ def foo(): print('ham') # indented two levels return bar # indented one level ``` -The first line is not indented. The next two lines are indented to be inside of the function definition. They will only run when the function is called. The fourth line is indented to be inside the `if` statement, and will only run if the `if` statement evaluates to `True`. The fifth and last line is like the 2nd and 3rd and will always run when the function is called. It effectively closes the `if` statement above as no more lines can be inside the `if` statement below that line. +The first line is not indented. The next two lines are indented to be inside of the function definition. They will only run when the function is called. The fourth line is indented to be inside the `if` statement, and will only run if the `if` statement evaluates to `True`. The fifth and last line is like the 2nd and 3rd and will always run when the function is called. It effectively closes the `if` statement above as no more lines can be inside the `if` statement below that line. **Indentation is used after:** **1.** [Compound statements](https://docs.python.org/3/reference/compound_stmts.html) (eg. `if`, `while`, `for`, `try`, `with`, `def`, `class`, and their counterparts) @@ -21,4 +21,4 @@ The first line is not indented. The next two lines are indented to be inside of **More Info** **1.** [Indentation style guide](https://www.python.org/dev/peps/pep-0008/#indentation) **2.** [Tabs or Spaces?](https://www.python.org/dev/peps/pep-0008/#tabs-or-spaces) -**3.** [Official docs on indentation](https://docs.python.org/3/reference/lexical_analysis.html#indentation) \ No newline at end of file +**3.** [Official docs on indentation](https://docs.python.org/3/reference/lexical_analysis.html#indentation) diff --git a/bot/resources/tags/inline.md b/bot/resources/tags/inline.md index d0c9d1b5e..a6a7c35d6 100644 --- a/bot/resources/tags/inline.md +++ b/bot/resources/tags/inline.md @@ -13,4 +13,4 @@ The `__init__` method customizes the newly created instance. **Note:** • These are **backticks** not quotes • Avoid using them for multiple lines -• Useful for negating formatting you don't want \ No newline at end of file +• Useful for negating formatting you don't want diff --git a/bot/resources/tags/iterate-dict.md b/bot/resources/tags/iterate-dict.md index b23475506..78c067b20 100644 --- a/bot/resources/tags/iterate-dict.md +++ b/bot/resources/tags/iterate-dict.md @@ -7,4 +7,4 @@ To iterate over both the keys and values: ```py for key, val in my_dict.items(): print(key, val) -``` \ No newline at end of file +``` diff --git a/bot/resources/tags/listcomps.md b/bot/resources/tags/listcomps.md index 5ef0ce2bc..0003b9bb8 100644 --- a/bot/resources/tags/listcomps.md +++ b/bot/resources/tags/listcomps.md @@ -11,4 +11,4 @@ even_numbers = [n for n in range(20) if n % 2 == 0] ``` This also works for generators, dicts and sets by using `()` or `{}` instead of `[]`. -For more info, see [this pythonforbeginners.com post](http://www.pythonforbeginners.com/basics/list-comprehensions-in-python) or [PEP 202](https://www.python.org/dev/peps/pep-0202/). \ No newline at end of file +For more info, see [this pythonforbeginners.com post](http://www.pythonforbeginners.com/basics/list-comprehensions-in-python) or [PEP 202](https://www.python.org/dev/peps/pep-0202/). diff --git a/bot/resources/tags/mutable-default-args.md b/bot/resources/tags/mutable-default-args.md index 7b16e6b82..a8f0c38b3 100644 --- a/bot/resources/tags/mutable-default-args.md +++ b/bot/resources/tags/mutable-default-args.md @@ -11,7 +11,7 @@ and returns it. `foo` is set to an empty list by default. >>> def append_one(foo=[]): ... foo.append(1) ... return foo -... +... ``` See what happens when we call it a few times: ```python @@ -33,7 +33,7 @@ function is **called**: ... foo = [] ... foo.append(1) ... return foo -... +... >>> append_one() [1] >>> append_one() @@ -45,4 +45,4 @@ function is **called**: • This behavior can be used intentionally to maintain state between calls of a function (eg. when writing a caching function). • This behavior is not unique to mutable objects, all default -arguments are evaulated only once when the function is defined. \ No newline at end of file +arguments are evaulated only once when the function is defined. diff --git a/bot/resources/tags/names.md b/bot/resources/tags/names.md index 462c550bc..3e76269f7 100644 --- a/bot/resources/tags/names.md +++ b/bot/resources/tags/names.md @@ -34,4 +34,4 @@ There is also `del` which has the purpose of *unbinding* a name. **More info** • Please watch [Ned Batchelder's talk](https://youtu.be/_AEJHKGk9ns) on names in python for a detailed explanation with examples -• [Official documentation](https://docs.python.org/3/reference/executionmodel.html#naming-and-binding) \ No newline at end of file +• [Official documentation](https://docs.python.org/3/reference/executionmodel.html#naming-and-binding) diff --git a/bot/resources/tags/off-topic.md b/bot/resources/tags/off-topic.md index 004adfa17..c7f98a813 100644 --- a/bot/resources/tags/off-topic.md +++ b/bot/resources/tags/off-topic.md @@ -1,8 +1,8 @@ **Off-topic channels** -There are three off-topic channels: -• <#291284109232308226> +There are three off-topic channels: +• <#291284109232308226> • <#463035241142026251> • <#463035268514185226> -Their names change randomly every 24 hours, but you can always find them under the `OFF-TOPIC/GENERAL` category in the channel list. \ No newline at end of file +Their names change randomly every 24 hours, but you can always find them under the `OFF-TOPIC/GENERAL` category in the channel list. diff --git a/bot/resources/tags/open.md b/bot/resources/tags/open.md index 1ba19dedd..13b4555b9 100644 --- a/bot/resources/tags/open.md +++ b/bot/resources/tags/open.md @@ -23,4 +23,4 @@ This is an optional string that specifies the mode in which the file should be o `'w+'` Opens for reading and writing and truncates (can create files) `'x'` Creates file and opens for writing (file must **not** already exist) `'x+'` Creates file and opens for reading and writing (file must **not** already exist) -`'a+'` Opens file for reading and writing at **end of file** (can create files) \ No newline at end of file +`'a+'` Opens file for reading and writing at **end of file** (can create files) diff --git a/bot/resources/tags/or-gotcha.md b/bot/resources/tags/or-gotcha.md index da82e3fdd..00c2db1f8 100644 --- a/bot/resources/tags/or-gotcha.md +++ b/bot/resources/tags/or-gotcha.md @@ -14,4 +14,4 @@ if favorite_fruit == 'grapefruit' or favorite_fruit == 'lemon': # ...or like this. if favorite_fruit in ('grapefruit', 'lemon'): print("That's a weird favorite fruit to have.") -``` \ No newline at end of file +``` diff --git a/bot/resources/tags/param-arg.md b/bot/resources/tags/param-arg.md index 9e946812b..88069d8bd 100644 --- a/bot/resources/tags/param-arg.md +++ b/bot/resources/tags/param-arg.md @@ -9,4 +9,4 @@ def square(n): # n is the parameter print(square(5)) # 5 is the argument ``` -Note that `5` is the argument passed to `square`, but `square(5)` in its entirety is the argument passed to `print` \ No newline at end of file +Note that `5` is the argument passed to `square`, but `square(5)` in its entirety is the argument passed to `print` diff --git a/bot/resources/tags/paste.md b/bot/resources/tags/paste.md index d8e6e6c61..2ed51def7 100644 --- a/bot/resources/tags/paste.md +++ b/bot/resources/tags/paste.md @@ -3,4 +3,4 @@ If your code is too long to fit in a codeblock in discord, you can paste your code here: https://paste.pydis.com/ -After pasting your code, **save** it by clicking the floppy disk icon in the top right, or by typing `ctrl + S`. After doing that, the URL should **change**. Copy the URL and post it here so others can see it. \ No newline at end of file +After pasting your code, **save** it by clicking the floppy disk icon in the top right, or by typing `ctrl + S`. After doing that, the URL should **change**. Copy the URL and post it here so others can see it. diff --git a/bot/resources/tags/pathlib.md b/bot/resources/tags/pathlib.md index 468945cc5..dfeb7ecac 100644 --- a/bot/resources/tags/pathlib.md +++ b/bot/resources/tags/pathlib.md @@ -18,4 +18,4 @@ Python 3 comes with a new module named `Pathlib`. Since Python 3.6, `pathlib.Pat • [**Why you should use pathlib** - Trey Hunner](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) • [**Answering concerns about pathlib** - Trey Hunner](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) • [**Official Documentation**](https://docs.python.org/3/library/pathlib.html) -• [**PEP 519** - Adding a file system path protocol](https://www.python.org/dev/peps/pep-0519/) \ No newline at end of file +• [**PEP 519** - Adding a file system path protocol](https://www.python.org/dev/peps/pep-0519/) diff --git a/bot/resources/tags/pep8.md b/bot/resources/tags/pep8.md index ec999bedc..cab4c4db8 100644 --- a/bot/resources/tags/pep8.md +++ b/bot/resources/tags/pep8.md @@ -1,3 +1,3 @@ **PEP 8** is the official style guide for Python. It includes comprehensive guidelines for code formatting, variable naming, and making your code easy to read. Professional Python developers are usually required to follow the guidelines, and will often use code-linters like `flake8` to verify that the code they\'re writing complies with the style guide. -You can find the PEP 8 document [here](https://www.python.org/dev/peps/pep-0008). \ No newline at end of file +You can find the PEP 8 document [here](https://www.python.org/dev/peps/pep-0008). diff --git a/bot/resources/tags/positional-keyword.md b/bot/resources/tags/positional-keyword.md index bc7f68ee0..dd6ddfc4b 100644 --- a/bot/resources/tags/positional-keyword.md +++ b/bot/resources/tags/positional-keyword.md @@ -25,7 +25,7 @@ The reverse is also true: ```py >>> def foo(a, b): ... print(a, b) -... +... >>> foo(a=1, b=2) 1 2 >>> foo(b=1, a=2) @@ -35,4 +35,4 @@ The reverse is also true: **More info** • [Keyword only arguments](https://www.python.org/dev/peps/pep-3102/) • [Positional only arguments](https://www.python.org/dev/peps/pep-0570/) -• `!tags param-arg` (Parameters vs. Arguments) \ No newline at end of file +• `!tags param-arg` (Parameters vs. Arguments) diff --git a/bot/resources/tags/precedence.md b/bot/resources/tags/precedence.md index 8a4c66c4e..ed399143c 100644 --- a/bot/resources/tags/precedence.md +++ b/bot/resources/tags/precedence.md @@ -10,4 +10,4 @@ Operator precedence is essentially like an order of operations for python's oper `not True or True` is `True` because the `not` is first `not (True or True)` is `False` because the `or` is first -The full table of precedence from lowest to highest is [here](https://docs.python.org/3/reference/expressions.html#operator-precedence) \ No newline at end of file +The full table of precedence from lowest to highest is [here](https://docs.python.org/3/reference/expressions.html#operator-precedence) diff --git a/bot/resources/tags/quotes.md b/bot/resources/tags/quotes.md index bb6e2a009..8421748a1 100644 --- a/bot/resources/tags/quotes.md +++ b/bot/resources/tags/quotes.md @@ -17,4 +17,4 @@ If you need both single and double quotes inside your string, use the version th **References:** • [pep-8 on quotes](https://www.python.org/dev/peps/pep-0008/#string-quotes) -• [convention for triple quoted strings](https://www.python.org/dev/peps/pep-0257/) \ No newline at end of file +• [convention for triple quoted strings](https://www.python.org/dev/peps/pep-0257/) diff --git a/bot/resources/tags/relative-path.md b/bot/resources/tags/relative-path.md index 269276e81..6e97b78af 100644 --- a/bot/resources/tags/relative-path.md +++ b/bot/resources/tags/relative-path.md @@ -4,4 +4,4 @@ A relative path is a partial path that is relative to your current working direc **Why is this important?** -When opening files in python, relative paths won't always work since it's dependent on what directory you were in when you ran your code. A common issue people face is running their code in an IDE thinking they can open files that are in the same directory as their module, but the current working directory will be different than what they expect and so they won't find the file. The way to avoid this problem is by using absolute paths, which is the full path from your root directory to the file you want to open. \ No newline at end of file +When opening files in python, relative paths won't always work since it's dependent on what directory you were in when you ran your code. A common issue people face is running their code in an IDE thinking they can open files that are in the same directory as their module, but the current working directory will be different than what they expect and so they won't find the file. The way to avoid this problem is by using absolute paths, which is the full path from your root directory to the file you want to open. diff --git a/bot/resources/tags/repl.md b/bot/resources/tags/repl.md index a68fe9397..875b4ec47 100644 --- a/bot/resources/tags/repl.md +++ b/bot/resources/tags/repl.md @@ -10,4 +10,4 @@ Alternatively, you can make use of the builtin `help()` function. `help(thing)` Lastly you can run your code with the `-i` flag to execute your code normally, but be dropped into the REPL once execution is finished, giving you access to all your global variables/functions in the REPL. -To **exit** either a help session, or normal REPL prompt, you must send an EOF signal to the prompt. In *nix systems, this is done with `ctrl + D`, and in windows systems it is `ctrl + Z`. You can also exit the normal REPL prompt with the dedicated functions `exit()` or `quit()`. \ No newline at end of file +To **exit** either a help session, or normal REPL prompt, you must send an EOF signal to the prompt. In *nix systems, this is done with `ctrl + D`, and in windows systems it is `ctrl + Z`. You can also exit the normal REPL prompt with the dedicated functions `exit()` or `quit()`. diff --git a/bot/resources/tags/return.md b/bot/resources/tags/return.md index c944dddf2..e37f0eebc 100644 --- a/bot/resources/tags/return.md +++ b/bot/resources/tags/return.md @@ -16,13 +16,13 @@ If we wanted to store 5 squared in a variable called `x`, we could do that like ```py >>> def square(n): ... n*n # calculates then throws away, returns None -... +... >>> x = square(5) >>> print(x) None >>> def square(n): ... print(n*n) # calculates and prints, then throws away and returns None -... +... >>> x = square(5) 25 >>> print(x) @@ -32,4 +32,4 @@ None • `print()` and `return` do **not** accomplish the same thing. `print()` will only print the value, it will not be accessible outside of the function afterwards. • A function will return `None` if it ends without reaching an explicit `return` statement. • When you want to print a value calculated in a function, instead of printing inside the function, it is often better to return the value and print the *function call* instead. -• [Official documentation for `return`](https://docs.python.org/3/reference/simple_stmts.html#the-return-statement) \ No newline at end of file +• [Official documentation for `return`](https://docs.python.org/3/reference/simple_stmts.html#the-return-statement) diff --git a/bot/resources/tags/round.md b/bot/resources/tags/round.md index 28a12469a..0392bb41b 100644 --- a/bot/resources/tags/round.md +++ b/bot/resources/tags/round.md @@ -21,4 +21,4 @@ It should be noted that round half to even distorts the distribution by increasi • [Wikipedia article about rounding](https://en.wikipedia.org/wiki/Rounding#Round_half_to_even) • [Documentation on `round` function](https://docs.python.org/3/library/functions.html#round) • [`round` in what's new in python 3](https://docs.python.org/3/whatsnew/3.0.html#builtins) (4th bullet down) -• [How to force rounding technique](https://stackoverflow.com/a/10826537/4607272) \ No newline at end of file +• [How to force rounding technique](https://stackoverflow.com/a/10826537/4607272) diff --git a/bot/resources/tags/scope.md b/bot/resources/tags/scope.md index c1eeb3b84..5c1e64e1c 100644 --- a/bot/resources/tags/scope.md +++ b/bot/resources/tags/scope.md @@ -13,7 +13,7 @@ Alternatively if a variable is defined within a function block for example, it i ... def inner(): ... print(foo) # has access to foo from scope of outer ... return inner # brings inner to scope of caller -... +... >>> inner = outer() # get inner function >>> inner() # prints variable foo without issue bar @@ -21,4 +21,4 @@ bar **Official Documentation** **1.** [Program structure, name binding and resolution](https://docs.python.org/3/reference/executionmodel.html#execution-model) **2.** [`global` statement](https://docs.python.org/3/reference/simple_stmts.html#the-global-statement) -**3.** [`nonlocal` statement](https://docs.python.org/3/reference/simple_stmts.html#the-nonlocal-statement) \ No newline at end of file +**3.** [`nonlocal` statement](https://docs.python.org/3/reference/simple_stmts.html#the-nonlocal-statement) diff --git a/bot/resources/tags/seek.md b/bot/resources/tags/seek.md index ff6569a0c..bc013fe03 100644 --- a/bot/resources/tags/seek.md +++ b/bot/resources/tags/seek.md @@ -19,4 +19,4 @@ Finally, lets do `f.seek(-4, 2)`, moving our stream position *backwards* 4 bytes **Note** • For the second argument in `seek()`, use `os.SEEK_SET`, `os.SEEK_CUR`, and `os.SEEK_END` in place of 0, 1, and 2 respectively. -• `os.SEEK_CUR` is only usable when the file is in byte mode. \ No newline at end of file +• `os.SEEK_CUR` is only usable when the file is in byte mode. diff --git a/bot/resources/tags/self.md b/bot/resources/tags/self.md index a9cd5e9df..d20154fd5 100644 --- a/bot/resources/tags/self.md +++ b/bot/resources/tags/self.md @@ -22,4 +22,4 @@ doing `Foo.spam(foo, 'ham')`. Methods do not inherently have access to attributes defined in the class. In order for any one method to be able to access other methods or variables defined in the class, it must have access to the instance. -Consider if outside the class, we tried to do this: `spam(foo, 'ham')`. This would give an error, because we don't have access to the `spam` method directly, we have to call it by doing `foo.spam('ham')`. This is also the case inside of the class. If we wanted to call the `bar` method inside the `spam` method, we'd have to do `self.bar()`, just doing `bar()` would give an error. \ No newline at end of file +Consider if outside the class, we tried to do this: `spam(foo, 'ham')`. This would give an error, because we don't have access to the `spam` method directly, we have to call it by doing `foo.spam('ham')`. This is also the case inside of the class. If we wanted to call the `bar` method inside the `spam` method, we'd have to do `self.bar()`, just doing `bar()` would give an error. diff --git a/bot/resources/tags/star-imports.md b/bot/resources/tags/star-imports.md index 4c7e0199c..2be6aab6e 100644 --- a/bot/resources/tags/star-imports.md +++ b/bot/resources/tags/star-imports.md @@ -45,4 +45,4 @@ Conclusion: Namespaces are one honking great idea -- let's do more of those! *[3 **[2]** [Namespaces and scopes](https://www.programiz.com/python-programming/namespace) -**[3]** [Zen of Python](https://www.python.org/dev/peps/pep-0020/) \ No newline at end of file +**[3]** [Zen of Python](https://www.python.org/dev/peps/pep-0020/) diff --git a/bot/resources/tags/traceback.md b/bot/resources/tags/traceback.md index 678ba1991..46ef40aa1 100644 --- a/bot/resources/tags/traceback.md +++ b/bot/resources/tags/traceback.md @@ -15,4 +15,4 @@ The best way to read your traceback is bottom to top. • Make note of the line number, and navigate there in your program. • Try to understand why the error occurred. -To read more about exceptions and errors, please refer to the [PyDis Wiki](https://pythondiscord.com/pages/asking-good-questions/#examining-tracebacks) or the [official Python tutorial.](https://docs.python.org/3.7/tutorial/errors.html) \ No newline at end of file +To read more about exceptions and errors, please refer to the [PyDis Wiki](https://pythondiscord.com/pages/asking-good-questions/#examining-tracebacks) or the [official Python tutorial.](https://docs.python.org/3.7/tutorial/errors.html) diff --git a/bot/resources/tags/windows-path.md b/bot/resources/tags/windows-path.md index d8723f06f..da8edf685 100644 --- a/bot/resources/tags/windows-path.md +++ b/bot/resources/tags/windows-path.md @@ -1,6 +1,6 @@ **PATH on Windows** -If you have installed Python but you forgot to check the *Add Python to PATH* option during the installation you may still be able to access your installation with ease. +If you have installed Python but you forgot to check the *Add Python to PATH* option during the installation you may still be able to access your installation with ease. If you did not uncheck the option to install the Python launcher then you will find a `py` command on your system. If you want to be able to open your Python installation by running `python` then your best option is to re-install Python. @@ -27,4 +27,4 @@ C:\Users\Username> py -3.6 ... Python 3.6 stars ... C:\Users\Username> py -2 ... Python 2 (any version installed) starts ... -``` \ No newline at end of file +``` diff --git a/bot/resources/tags/with.md b/bot/resources/tags/with.md index a79eb7dbb..62d5612f2 100644 --- a/bot/resources/tags/with.md +++ b/bot/resources/tags/with.md @@ -5,4 +5,4 @@ with open("test.txt", "r") as file: ``` The above code automatically closes `file` when the `with` block exits, so you never have to manually do a `file.close()`. Most connection types, including file readers and database connections, support this. -For more information, read [the official docs](https://docs.python.org/3/reference/compound_stmts.html#with), watch [Corey Schafer\'s context manager video](https://www.youtube.com/watch?v=-aKFBoZpiqA), or see [PEP 343](https://www.python.org/dev/peps/pep-0343/). \ No newline at end of file +For more information, read [the official docs](https://docs.python.org/3/reference/compound_stmts.html#with), watch [Corey Schafer\'s context manager video](https://www.youtube.com/watch?v=-aKFBoZpiqA), or see [PEP 343](https://www.python.org/dev/peps/pep-0343/). diff --git a/bot/resources/tags/xy-problem.md b/bot/resources/tags/xy-problem.md index 77700e7a0..b77bd27e8 100644 --- a/bot/resources/tags/xy-problem.md +++ b/bot/resources/tags/xy-problem.md @@ -4,4 +4,4 @@ Asking about your attempted solution rather than your actual problem. Often programmers will get distracted with a potential solution they've come up with, and will try asking for help getting it to work. However, it's possible this solution either wouldn't work as they expect, or there's a much better solution instead. -For more information and examples: http://xyproblem.info/ \ No newline at end of file +For more information and examples: http://xyproblem.info/ diff --git a/bot/resources/tags/ytdl.md b/bot/resources/tags/ytdl.md index e1085d1af..09664af26 100644 --- a/bot/resources/tags/ytdl.md +++ b/bot/resources/tags/ytdl.md @@ -6,4 +6,4 @@ For reference, this usage is covered by the following clauses in [YouTube's TOS] ``` ``` 4C: You agree not to access Content through any technology or means other than the video playback pages of the Service itself, the Embeddable Player, or other explicitly authorized means YouTube may designate. -``` \ No newline at end of file +``` diff --git a/bot/resources/tags/zip.md b/bot/resources/tags/zip.md index 9d2fe5ee3..6b05f0282 100644 --- a/bot/resources/tags/zip.md +++ b/bot/resources/tags/zip.md @@ -9,4 +9,4 @@ for letter, number in zip(letters, numbers): ``` The `zip()` iterator is exhausted after the length of the shortest iterable is exceeded. If you would like to retain the other values, consider using [itertools.zip_longest](https://docs.python.org/3/library/itertools.html#itertools.zip_longest). -For more information on zip, please refer to the [official documentation](https://docs.python.org/3/library/functions.html#zip). \ No newline at end of file +For more information on zip, please refer to the [official documentation](https://docs.python.org/3/library/functions.html#zip). -- cgit v1.2.3 From 25369cb35930b939eaf29d49cbf9fcce327607f2 Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 5 Mar 2020 09:00:18 +0200 Subject: (Information Cog, !roles command): Added empty parameter to pagination (False) --- bot/cogs/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 4dd4a7e75..807c2264d 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -44,7 +44,7 @@ class Information(Cog): colour=Colour.blurple() ) - await LinePaginator.paginate(role_list, ctx, embed) + await LinePaginator.paginate(role_list, ctx, embed, empty=False) @with_role(*constants.MODERATION_ROLES) @command(name="role") -- cgit v1.2.3 From 5579f2d32d5faadad778d64c50cf6fbefccf4f28 Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 5 Mar 2020 09:05:06 +0200 Subject: (Information Cog, !roles command test): Applied empty parameter change. --- tests/bot/cogs/test_information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index c6fd937b8..7c265bba8 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -47,7 +47,7 @@ class InformationCogTests(unittest.TestCase): self.assertEqual(embed.title, "Role information (Total 1 roles)") self.assertEqual(embed.colour, discord.Colour.blurple()) - self.assertEqual(embed.description, f"\n`{self.moderator_role.id}` - {self.moderator_role.mention}\n\n") + 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.""" -- cgit v1.2.3 From 028d47821293b6004a7322bdbee28b5a484dd673 Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 5 Mar 2020 09:07:02 +0200 Subject: (Information Cog, !roles command): Added 's' to end of 'role' only if there is more then 1 role. --- bot/cogs/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 807c2264d..7921a4932 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -40,7 +40,7 @@ class Information(Cog): # Build an embed embed = Embed( - title=f"Role information (Total {len(roles)} roles)", + title=f"Role information (Total {len(roles)} role{'s' * (len(role_list) > 1)})", colour=Colour.blurple() ) -- cgit v1.2.3 From 0b75d3f5e717f99f53522d4224abea6223ef6c84 Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 5 Mar 2020 09:08:10 +0200 Subject: (Information Cog, !roles command test): Removed 's' at end of "Total 1 role(s)" due changes in command. --- tests/bot/cogs/test_information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 7c265bba8..3c26374f5 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -45,7 +45,7 @@ class InformationCogTests(unittest.TestCase): _, kwargs = self.ctx.send.call_args embed = kwargs.pop('embed') - self.assertEqual(embed.title, "Role information (Total 1 roles)") + self.assertEqual(embed.title, "Role information (Total 1 role)") self.assertEqual(embed.colour, discord.Colour.blurple()) self.assertEqual(embed.description, f"\n`{self.moderator_role.id}` - {self.moderator_role.mention}\n") -- cgit v1.2.3 From aa6113792ca9c328c428fc1ea75cac3b03bcd7f3 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Thu, 5 Mar 2020 22:58:07 +1000 Subject: Re-use embed, use command converter, raise BadArgument. --- bot/cogs/utils.py | 59 +++++++++++++++++++++---------------------------------- 1 file changed, 22 insertions(+), 37 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index df2254e0b..49fe6d344 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -1,19 +1,18 @@ import difflib import logging -import random import re import unicodedata from asyncio import TimeoutError, sleep from email.parser import HeaderParser from io import StringIO -from typing import Optional, 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, NEGATIVE_REPLIES, STAFF_ROLES +from bot.constants import Channels, MODERATION_ROLES, Mention, STAFF_ROLES from bot.decorators import in_channel, with_role from bot.utils.time import humanize_delta @@ -198,7 +197,7 @@ class Utils(Cog): ) @command() - async def zen(self, ctx: Context, *, search_value: Optional[str] = None) -> None: + async def zen(self, ctx: Context, *, search_value: Union[int, str, None] = None) -> None: """ Show the Zen of Python. @@ -206,42 +205,32 @@ class Utils(Cog): 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. """ - if search_value is None: - embed = Embed( - colour=Colour.blurple(), - title="The Zen of Python, by Tim Peters", - description=ZEN_OF_PYTHON - ) + 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() - # check if it's an integer. could be negative. why not. - is_negative_integer = search_value[0] == "-" and search_value[1:].isdigit() - if search_value.isdigit() or is_negative_integer: - index = int(search_value) - - try: - line = zen_lines[index] - except IndexError: - embed = Embed( - colour=Colour.red(), - title=random.choice(NEGATIVE_REPLIES), - description=f"Please provide an index between {-len(zen_lines)} and {len(zen_lines) - 1}." - ) - else: - embed = Embed( - colour=Colour.blurple(), - title=f"The Zen of Python (line {index % len(zen_lines)}):", - description=line - ) + # 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 - # at this point, we must be dealing with a string search. + # handle if it's a search string matcher = difflib.SequenceMatcher(None, search_value.lower()) best_match = "" @@ -261,12 +250,8 @@ class Utils(Cog): best_match = line match_index = index - embed = Embed( - colour=Colour.blurple(), - title=f"The Zen of Python (line {match_index}):", - description=best_match - ) - + embed.title += f" (line {match_index}):" + embed.description = best_match await ctx.send(embed=embed) -- cgit v1.2.3 From d515941ac0af2e176d186bed5d8fafb1cec13de4 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Thu, 5 Mar 2020 23:33:44 +1000 Subject: Raise BadArgument if no string match. --- bot/cogs/utils.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 49fe6d344..8ea972145 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -250,6 +250,9 @@ class Utils(Cog): 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) -- cgit v1.2.3 From 8769cfcfd85e2137992e1e34df214936b1ed9425 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 5 Mar 2020 09:04:46 -0800 Subject: CI: don't show diff after pre-commit hooks It's noisy, messy output. It's not of much benefit anyway as users can run git diff locally if they really need to see a diff. They have to do work locally anyway since CI won't commit the fixes pre-commit makes. --- azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 -- cgit v1.2.3 From 7f2dacae2b12c3d0a26519cc0d1f22ea5bc2fc50 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 5 Mar 2020 09:06:25 -0800 Subject: Remove excludes from pre-commit It was excluding files that are already ignored by git. Pre-commit respects git ignore, even with --all-files, so these ignores were redundant. --- .pre-commit-config.yaml | 1 - 1 file changed, 1 deletion(-) 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 -- cgit v1.2.3 From 76a9a03a7b4334fd9606f0940c78111f3cfec9ea Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Fri, 6 Mar 2020 14:35:17 -0600 Subject: Added BigBrother Helper Methods - Added apply_unwatch() and migrated the code from the unwatch command to it. This will give us more control regarding testing and also determining when unwatches trigger. - Added apply_watch() and migrated the code from the watch command to it. Again, this will assist with testing and could make it easier to automate adding to the watch list if need be. - Added unwatch call to apply_ban. User will only be removed from the watch list if they were permanently banned. They will not be removed if it was only temporary. Signed-off-by: Daniel Brown --- bot/cogs/moderation/infractions.py | 13 ++++++++++++- bot/cogs/watchchannels/bigbrother.py | 22 ++++++++++++++++------ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 9ea17b2b3..9bab38e23 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -67,7 +67,7 @@ class Infractions(InfractionScheduler, commands.Cog): @command() async def ban(self, ctx: Context, user: FetchedMember, *, reason: str = None) -> None: - """Permanently ban a user for the given reason.""" + """Permanently ban a user for the given reason. Also removes them from the BigBrother watch list.""" await self.apply_ban(ctx, user, reason) # endregion @@ -243,6 +243,17 @@ class Infractions(InfractionScheduler, commands.Cog): action = ctx.guild.ban(user, reason=reason, delete_message_days=0) await self.apply_infraction(ctx, infraction, user, action) + # Remove perma banned users from the watch list + if 'expires_at' not in kwargs: + bb_cog = self.bot.get_cog("BigBrother") + if bb_cog: + await bb_cog.apply_unwatch( + ctx, + user, + "User has been permanently banned from the server. Automatically removed.", + banned=True + ) + # endregion # region: Base pardon functions diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index c601e0d4d..75b66839e 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -52,6 +52,16 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): A `reason` for adding the user to Big Brother is required and will be displayed in the header when relaying messages of this user to the watchchannel. """ + await self.apply_watch(ctx, user, reason) + + @bigbrother_group.command(name='unwatch', aliases=('uw',)) + @with_role(*MODERATION_ROLES) + async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """Stop relaying messages by the given `user`.""" + await self.apply_unwatch(ctx, user, reason) + + async def apply_watch(self, ctx: Context, user: FetchedMember, reason: str) -> None: + """Handles adding a user to the watch list.""" if user.bot: await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") return @@ -90,10 +100,8 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await ctx.send(msg) - @bigbrother_group.command(name='unwatch', aliases=('uw',)) - @with_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Stop relaying messages by the given `user`.""" + async def apply_unwatch(self, ctx: Context, user: FetchedMember, reason: str, banned: bool = False) -> None: + """Handles the actual user removal from the watch list.""" active_watches = await self.bot.api_client.get( self.api_endpoint, params=ChainMap( @@ -111,8 +119,10 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await post_infraction(ctx, user, 'watch', f"Unwatched: {reason}", hidden=True, active=False) - await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed.") + if not banned: # Prevents a message being sent to the channel if part of a permanent ban + await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed.") self._remove_user(user.id) else: - await ctx.send(":x: The specified user is currently not being watched.") + if not banned: # Prevents a message being sent to the channel if part of a permanent ban + await ctx.send(":x: The specified user is currently not being watched.") -- cgit v1.2.3 From e7dde8f29212b21edf241b2d821e7c40a282b5d8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 7 Mar 2020 16:42:16 -0800 Subject: ModLog: fix posting null attachments for deleted message logs If attachments are not given to `upload_log`, an empty list is used. By default, `zip_longest` uses `None` ass the fill value, so each message was getting paired with a `None` (AKA null) attachment. The filed in the DB is non-nullable so an empty list must be used instead. Fixes #792 --- bot/cogs/moderation/modlog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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=[]) ] } ) -- cgit v1.2.3 From 2e81f05c078bfcff837db1786d535a8cc767ec0f Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Sun, 8 Mar 2020 11:40:32 +0700 Subject: Implemented `search` as a subcommand for `tag` that will search in contents instead of names - `!tag search` will search for multiple keywords, separated by comma, and return tags that has ALL of these keywords. ` !tag search any` is the same as `!tag search` but it return tags that has ANY of the keyword instead. --- bot/cogs/tags.py | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 5da9a4148..965a29596 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -1,7 +1,7 @@ import logging import re import time -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Tuple from discord import Colour, Embed from discord.ext.commands import Cog, Context, group @@ -86,11 +86,60 @@ class Tags(Cog): return self._get_suggestions(tag_name) return found + async def _get_tags_via_content(self, check: callable, keywords: str) -> Optional[Embed]: + """ + Search for tags via contents. + + `predicate` will be either any or all, or a custom callable to search. Must return a bool. + """ + await self._get_tags() + + keywords_processed: Tuple[str] = tuple(query.strip().casefold() for query in keywords.split(',')) + founds: list = [ + tag + for tag in self._cache.values() + if check(query in tag['embed']['description'] for query in keywords_processed) + ] + + if not founds: + return None + elif len(founds) == 1: + return Embed().from_dict(founds[0]['embed']) + else: + return Embed( + title='Did you mean ...', + description='\n'.join(tag['title'] for tag in founds[:10]) + ) + @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. + """ + result = await self._get_tags_via_content(all, keywords) + if not result: + return + await ctx.send(embed=result) + + @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. + """ + result = await self._get_tags_via_content(any, keywords or 'any') + if not result: + return + await ctx.send(embed=result) + @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.""" -- cgit v1.2.3 From 76fccc1ea47162346d60736db638eea7166222ae Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Sun, 8 Mar 2020 12:06:10 +0700 Subject: Refactored tag searching via keywords in contents - Refactored `if` block - change to only send result when there is any result. - Added better type hinting for `check` argument of `_get_tags_via_content` - changed from `callable` to `Callable[[Iterable], bool]`. Thanks to @markkoz 's reviews Co-Authored-By: Mark --- bot/cogs/tags.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 965a29596..63b529945 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -1,7 +1,7 @@ import logging import re import time -from typing import Dict, List, Optional, Tuple +from typing import Callable, Dict, Iterable, List, Optional, Tuple from discord import Colour, Embed from discord.ext.commands import Cog, Context, group @@ -86,7 +86,7 @@ class Tags(Cog): return self._get_suggestions(tag_name) return found - async def _get_tags_via_content(self, check: callable, keywords: str) -> Optional[Embed]: + async def _get_tags_via_content(self, check: Callable[[Iterable], bool], keywords: str) -> Optional[Embed]: """ Search for tags via contents. @@ -124,9 +124,8 @@ class Tags(Cog): Only search for tags that has ALL the keywords. """ result = await self._get_tags_via_content(all, keywords) - if not result: - return - await ctx.send(embed=result) + if result: + await ctx.send(embed=result) @search_tag_content.command(name='any') async def search_tag_content_any_keyword(self, ctx: Context, *, keywords: Optional[str] = None) -> None: @@ -136,9 +135,8 @@ class Tags(Cog): Search for tags that has ANY of the keywords. """ result = await self._get_tags_via_content(any, keywords or 'any') - if not result: - return - await ctx.send(embed=result) + if result: + await ctx.send(embed=result) @tags_group.command(name='get', aliases=('show', 'g')) async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: -- cgit v1.2.3 From 89f86f873d7cd6ade626a0a91c5d9e09c5c14102 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Sun, 8 Mar 2020 12:37:42 +0700 Subject: Fixed searching for `,` returing all tags. Made it more descriptive when multiple tags are found. - Added a truthy check for each `query` since `','.split()` returns a list of two empty strings. - Changed from `Did you mean ...` to `Here are the tags containing the given keyword(s):` to be much more descriptive about the results - they are `tag` and not `term` to be searched. --- bot/cogs/tags.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 63b529945..49ed87c92 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -94,7 +94,8 @@ class Tags(Cog): """ await self._get_tags() - keywords_processed: Tuple[str] = tuple(query.strip().casefold() for query in keywords.split(',')) + keywords_processed: Tuple[str] = tuple(query.strip().casefold() for query in keywords.split(',') if query) + keywords_processed = keywords_processed or (keywords,) founds: list = [ tag for tag in self._cache.values() @@ -106,10 +107,13 @@ class Tags(Cog): elif len(founds) == 1: return Embed().from_dict(founds[0]['embed']) else: - return Embed( - title='Did you mean ...', + is_plural: bool = len(keywords_processed) > 1 or any(kw.count(' ') for kw in keywords_processed) + embed = Embed( + title=f"Here are the tags containing the given keyword{'s' * is_plural}:", description='\n'.join(tag['title'] for tag in founds[:10]) ) + embed.set_footer(text=f"Keyword{'s' * is_plural} used: {keywords}"[:1024]) + return embed @group(name='tags', aliases=('tag', 't'), invoke_without_command=True) async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: -- cgit v1.2.3 From dd707182b4f4f4ce98353d5c82092f48dd8fb5c2 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Sun, 8 Mar 2020 21:30:18 +0700 Subject: Refactored dense codes, removed obvious type hint. - Show the process of sanitizing the List[str] `keywords_processed`. - Show the process of finding tag for `matching_tags` ( was `founds` ). - Refactored the logic to find boolean `is_plural`. - Minor wording changes for docstring. --- bot/cogs/tags.py | 40 +++++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 49ed87c92..89f3acb6d 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -1,7 +1,7 @@ import logging import re import time -from typing import Callable, Dict, Iterable, List, Optional, Tuple +from typing import Callable, Dict, Iterable, List, Optional from discord import Colour, Embed from discord.ext.commands import Cog, Context, group @@ -90,27 +90,37 @@ class Tags(Cog): """ Search for tags via contents. - `predicate` will be either any or all, or a custom callable to search. Must return a bool. + `predicate` will be the built-in any, all, or a custom callable. Must return a bool. """ await self._get_tags() - keywords_processed: Tuple[str] = tuple(query.strip().casefold() for query in keywords.split(',') if query) - keywords_processed = keywords_processed or (keywords,) - founds: list = [ - tag - for tag in self._cache.values() - if check(query in tag['embed']['description'] for query in keywords_processed) - ] - - if not founds: + 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) + + if not matching_tags: return None - elif len(founds) == 1: - return Embed().from_dict(founds[0]['embed']) + elif len(matching_tags) == 1: + return Embed().from_dict(matching_tags[0]['embed']) else: - is_plural: bool = len(keywords_processed) > 1 or any(kw.count(' ') for kw in keywords_processed) + is_plural = len(keywords_processed) > 1 or keywords.strip().count(' ') > 1 embed = Embed( title=f"Here are the tags containing the given keyword{'s' * is_plural}:", - description='\n'.join(tag['title'] for tag in founds[:10]) + description='\n'.join(tag['title'] for tag in matching_tags[:10]) ) embed.set_footer(text=f"Keyword{'s' * is_plural} used: {keywords}"[:1024]) return embed -- cgit v1.2.3 From 139a7148e1ec51ae41cff12c3d32ab6f52c95aef Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Sun, 8 Mar 2020 23:01:02 +0700 Subject: Fixed `is_plural` counting 1 less space. Co-Authored-By: Mark --- bot/cogs/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 89f3acb6d..e3ade07a9 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -117,7 +117,7 @@ class Tags(Cog): elif len(matching_tags) == 1: return Embed().from_dict(matching_tags[0]['embed']) else: - is_plural = len(keywords_processed) > 1 or keywords.strip().count(' ') > 1 + is_plural = len(keywords_processed) > 1 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]) -- cgit v1.2.3 From 4254053af6980c75a845ddcc7f1701f7b86a42a9 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 8 Mar 2020 19:15:17 +0100 Subject: Restrict cog to moderators. --- bot/cogs/moderation/silence.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 560a0a15c..0081a420e 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -8,8 +8,9 @@ from discord.ext import commands, tasks from discord.ext.commands import Context, TextChannelConverter from bot.bot import Bot -from bot.constants import Channels, Emojis, Guild, Roles +from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles from bot.converters import HushDurationConverter +from bot.utils.checks import with_role_check log = logging.getLogger(__name__) @@ -147,3 +148,8 @@ class Silence(commands.Cog): @_notifier.after_loop async def _log_notifier_end(self) -> None: log.trace("Stopping notifier loop.") + + # This cannot be static (must have a __func__ attribute). + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *MODERATION_ROLES) -- cgit v1.2.3 From 8be3c7d1a65bf5d0e0bc07267e7c45f143fae2e6 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 8 Mar 2020 19:39:53 +0100 Subject: Add handling for shh/unshh for `CommandNotFound`. --- bot/cogs/error_handler.py | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 261769efc..45ab1f326 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -31,7 +31,9 @@ class ErrorHandler(Cog): Error handling emits a single error message in the invoking context `ctx` and a log message, prioritised as follows: - 1. If the name fails to match a command but matches a tag, the tag is invoked + 1. If the name fails to match a command: + If it matches shh+ or unshh+, the channel is silenced or unsilenced respectively. + otherwise if it matches a tag, the tag is invoked * If CommandNotFound is raised when invoking the tag (determined by the presence of the `invoked_from_error_handler` attribute), this error is treated as being unexpected and therefore sends an error message @@ -49,10 +51,14 @@ class ErrorHandler(Cog): return # Try to look for a tag with the command's name if the command isn't found. - if isinstance(e, errors.CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): - if ctx.channel.id != Channels.verification: + if isinstance(e, errors.CommandNotFound): + if ( + not await self.try_silence(ctx) + and not hasattr(ctx, "invoked_from_error_handler") + and ctx.channel.id != Channels.verification + ): await self.try_get_tag(ctx) - return # Exit early to avoid logging. + return # Exit early to avoid logging. elif isinstance(e, errors.UserInputError): await self.handle_user_input_error(ctx, e) elif isinstance(e, errors.CheckFailure): @@ -89,6 +95,28 @@ class ErrorHandler(Cog): else: return self.bot.get_command("help") + async def try_silence(self, ctx: Context) -> bool: + """ + Attempt to invoke the silence or unsilence command if invoke with matches a pattern. + + Respecting the checks if: + invoked with `shh+` silence channel for amount of h's*2 with max of 15. + invoked with `unshh+` unsilence channel + Return bool depending on success of command. + """ + command = ctx.invoked_with.lower() + silence_command = self.bot.get_command("silence") + if not await silence_command.can_run(ctx): + log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.") + return False + if command.startswith("shh"): + await ctx.invoke(silence_command, duration=min(command.count("h")*2, 15)) + return True + elif command.startswith("unshh"): + await ctx.invoke(self.bot.get_command("unsilence")) + return True + return False + async def try_get_tag(self, ctx: Context) -> None: """ Attempt to display a tag by interpreting the command name as a tag name. -- cgit v1.2.3 From d4253e106771f90a983717a994349d52337b2de9 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 8 Mar 2020 19:40:34 +0100 Subject: Add tests for FirstHash class. --- tests/bot/cogs/moderation/__init__.py | 0 tests/bot/cogs/moderation/test_silence.py | 25 +++++++++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 tests/bot/cogs/moderation/__init__.py create mode 100644 tests/bot/cogs/moderation/test_silence.py diff --git a/tests/bot/cogs/moderation/__init__.py b/tests/bot/cogs/moderation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py new file mode 100644 index 000000000..2a06f5944 --- /dev/null +++ b/tests/bot/cogs/moderation/test_silence.py @@ -0,0 +1,25 @@ +import unittest + +from bot.cogs.moderation.silence import FirstHash + + +class FirstHashTests(unittest.TestCase): + def setUp(self) -> None: + self.test_cases = ( + (FirstHash(0, 4), FirstHash(0, 5)), + (FirstHash("string", None), FirstHash("string", True)) + ) + + def test_hashes_equal(self): + """Check hashes equal with same first item.""" + + for tuple1, tuple2 in self.test_cases: + with self.subTest(tuple1=tuple1, tuple2=tuple2): + self.assertEqual(hash(tuple1), hash(tuple2)) + + def test_eq(self): + """Check objects are equal with same first item.""" + + for tuple1, tuple2 in self.test_cases: + with self.subTest(tuple1=tuple1, tuple2=tuple2): + self.assertTrue(tuple1 == tuple2) -- cgit v1.2.3 From e872176b452ceca1b639ef42d640e18656c7c0c9 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 8 Mar 2020 19:42:18 +0100 Subject: Add test case for Silence cog. --- tests/bot/cogs/moderation/test_silence.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 2a06f5944..1db2b6eec 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -1,6 +1,7 @@ import unittest -from bot.cogs.moderation.silence import FirstHash +from bot.cogs.moderation.silence import FirstHash, Silence +from tests.helpers import MockBot, MockContext class FirstHashTests(unittest.TestCase): @@ -23,3 +24,11 @@ class FirstHashTests(unittest.TestCase): for tuple1, tuple2 in self.test_cases: with self.subTest(tuple1=tuple1, tuple2=tuple2): self.assertTrue(tuple1 == tuple2) + + +class SilenceTests(unittest.TestCase): + def setUp(self) -> None: + + self.bot = MockBot() + self.cog = Silence(self.bot) + self.ctx = MockContext() -- cgit v1.2.3 From 1d83a5752aae483224129ee798e529f3d7d8e132 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 8 Mar 2020 19:42:51 +0100 Subject: Add test for `silence` discord output. --- tests/bot/cogs/moderation/test_silence.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 1db2b6eec..088410bee 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -1,6 +1,10 @@ +import asyncio import unittest +from functools import partial +from unittest import mock from bot.cogs.moderation.silence import FirstHash, Silence +from bot.constants import Emojis from tests.helpers import MockBot, MockContext @@ -32,3 +36,23 @@ class SilenceTests(unittest.TestCase): self.bot = MockBot() self.cog = Silence(self.bot) self.ctx = MockContext() + + def test_silence_sent_correct_discord_message(self): + """Check if proper message was sent when called with duration in channel with previous state.""" + test_cases = ( + ((self.cog, self.ctx, 0.0001), f"{Emojis.check_mark} #channel silenced for 0.0001 minute(s).", True,), + ((self.cog, self.ctx, None), f"{Emojis.check_mark} #channel silenced indefinitely.", True,), + ((self.cog, self.ctx, 5), f"{Emojis.cross_mark} #channel is already silenced.", False,), + ) + for silence_call_args, result_message, _silence_patch_return in test_cases: + with self.subTest( + silence_duration=silence_call_args[-1], + result_message=result_message, + starting_unsilenced_state=_silence_patch_return + ): + with mock.patch( + "bot.cogs.moderation.silence.Silence._silence", + new_callable=partial(mock.AsyncMock, return_value=_silence_patch_return) + ): + asyncio.run(self.cog.silence.callback(*silence_call_args)) + self.ctx.send.call_args.assert_called_once_with(result_message) -- cgit v1.2.3 From 33a6aac8d6c9f2739128fdc10f9f8205507a62d4 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Sun, 8 Mar 2020 16:21:55 -0400 Subject: Fix filtered extension string out of scope for log message * Fix typo in file extensions list comprehension --- bot/cogs/antimalware.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 = ( -- cgit v1.2.3 From cfbe3b9742b5531bdced1d5b099739f01033a6bb Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 8 Mar 2020 22:20:00 +0100 Subject: Add test for `unsilence` discord output. --- tests/bot/cogs/moderation/test_silence.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 088410bee..17420ce7d 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -56,3 +56,12 @@ class SilenceTests(unittest.TestCase): ): asyncio.run(self.cog.silence.callback(*silence_call_args)) self.ctx.send.call_args.assert_called_once_with(result_message) + + def test_unsilence_sent_correct_discord_message(self): + """Check if proper message was sent to `alert_chanel`.""" + with mock.patch( + "bot.cogs.moderation.silence.Silence._unsilence", + new_callable=partial(mock.AsyncMock, return_value=True) + ): + asyncio.run(self.cog.unsilence.callback(self.cog, self.ctx)) + self.ctx.channel.send.call_args.assert_called_once_with(f"{Emojis.check_mark} Unsilenced #channel.") -- cgit v1.2.3 From 7acaa717aab47f470353dcb49ee0202e86339d7c Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 8 Mar 2020 22:20:43 +0100 Subject: Use `Context.invoke` instead of calling `unsilence` directly. Calling the command coro directly did unnecessary checks and made tests for the method harder to realize. --- bot/cogs/moderation/silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 0081a420e..266d6dedd 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -68,7 +68,7 @@ class Silence(commands.Cog): await ctx.send(f"{Emojis.check_mark} {channel.mention} silenced for {duration} minute(s).") await asyncio.sleep(duration*60) - await self.unsilence(ctx, channel) + await ctx.invoke(self.unsilence, channel=channel) @commands.command(aliases=("unhush",)) async def unsilence(self, ctx: Context, channel: TextChannelConverter = None) -> None: -- cgit v1.2.3 From 01c7f193806e494408792eb3907280dccad3eacf Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 8 Mar 2020 22:20:57 +0100 Subject: Remove "Channel" from output string for consistency. --- bot/cogs/moderation/silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 266d6dedd..10185761c 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -63,7 +63,7 @@ class Silence(commands.Cog): await ctx.send(f"{Emojis.cross_mark} {channel.mention} is already silenced.") return if duration is None: - await ctx.send(f"{Emojis.check_mark} Channel {channel.mention} silenced indefinitely.") + await ctx.send(f"{Emojis.check_mark} {channel.mention} silenced indefinitely.") return await ctx.send(f"{Emojis.check_mark} {channel.mention} silenced for {duration} minute(s).") -- cgit v1.2.3 From 57e7fc0b02704dd65b3307e92be87237f806cb68 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 8 Mar 2020 22:21:43 +0100 Subject: Move notifier to separate class. Separating the notifier allows us to keep the Silence class and its methods to be more focused on the class' purpose, handling the logic of adding/removing channels and the loop itself behind `SilenceNotifier`'s interface. --- bot/cogs/moderation/silence.py | 86 ++++++++++++++++++++++-------------------- 1 file changed, 46 insertions(+), 40 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 10185761c..e12b6c606 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -29,21 +29,59 @@ class FirstHash(tuple): return self[0] == other[0] +class SilenceNotifier(tasks.Loop): + """Loop notifier for posting notices to `alert_channel` containing added channels.""" + + def __init__(self, alert_channel: TextChannel): + super().__init__(self._notifier, seconds=1, minutes=0, hours=0, count=None, reconnect=True, loop=None) + self._silenced_channels = set() + self._alert_channel = alert_channel + + def add_channel(self, channel: TextChannel) -> None: + """Add channel to `_silenced_channels` and start loop if not launched.""" + if not self._silenced_channels: + self.start() + log.trace("Starting notifier loop.") + self._silenced_channels.add(FirstHash(channel, self._current_loop)) + + def remove_channel(self, channel: TextChannel) -> None: + """Remove channel from `_silenced_channels` and stop loop if no channels remain.""" + with suppress(KeyError): + self._silenced_channels.remove(FirstHash(channel)) + if not self._silenced_channels: + self.stop() + log.trace("Stopping notifier loop.") + + async def _notifier(self) -> None: + """Post notice of `_silenced_channels` with their silenced duration to `_alert_channel` periodically.""" + # Wait for 15 minutes between notices with pause at start of loop. + if self._current_loop and not self._current_loop/60 % 15: + log.debug( + f"Sending notice with channels: " + f"{', '.join(f'#{channel} ({channel.id})' for channel, _ in self._silenced_channels)}." + ) + channels_text = ', '.join( + f"{channel.mention} for {(self._current_loop-start)//60} min" + for channel, start in self._silenced_channels + ) + await self._alert_channel.send(f"<@&{Roles.moderators}> currently silenced channels: {channels_text}") + + class Silence(commands.Cog): """Commands for stopping channel messages for `verified` role in a channel.""" def __init__(self, bot: Bot): self.bot = bot - self.loop_alert_channels = set() - self.bot.loop.create_task(self._get_server_values()) + self.bot.loop.create_task(self._get_instance_vars()) - async def _get_server_values(self) -> None: - """Fetch required internal values after they're available.""" + async def _get_instance_vars(self) -> None: + """Get instance variables after they're available to get from the guild.""" await self.bot.wait_until_guild_available() guild = self.bot.get_guild(Guild.id) self._verified_role = guild.get_role(Roles.verified) self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts) self._mod_log_channel = self.bot.get_channel(Channels.mod_log) + self.notifier = SilenceNotifier(self._mod_log_channel) @commands.command(aliases=("hush",)) async def silence( @@ -87,8 +125,7 @@ class Silence(commands.Cog): """ Silence `channel` for `self._verified_role`. - If `persistent` is `True` add `channel` with current iteration of `self._notifier` - to `self.self.loop_alert_channels` and attempt to start notifier. + If `persistent` is `True` add `channel` to notifier. `duration` is only used for logging; if None is passed `persistent` should be True to not log None. """ if channel.overwrites_for(self._verified_role).send_messages is False: @@ -97,9 +134,7 @@ class Silence(commands.Cog): await channel.set_permissions(self._verified_role, overwrite=PermissionOverwrite(send_messages=False)) if persistent: log.debug(f"Silenced #{channel} ({channel.id}) indefinitely.") - self.loop_alert_channels.add(FirstHash(channel, self._notifier.current_loop)) - with suppress(RuntimeError): - self._notifier.start() + self.notifier.add_channel(channel) return True log.debug(f"Silenced #{channel} ({channel.id}) for {duration} minute(s).") @@ -110,45 +145,16 @@ class Silence(commands.Cog): Unsilence `channel`. Check if `channel` is silenced through a `PermissionOverwrite`, - if it is unsilence it, attempt to remove it from `self.loop_alert_channels` - and if `self.loop_alert_channels` are left empty, stop the `self._notifier` + if it is unsilence it and remove it from the notifier. """ if channel.overwrites_for(self._verified_role).send_messages is False: await channel.set_permissions(self._verified_role, overwrite=None) log.debug(f"Unsilenced channel #{channel} ({channel.id}).") - - with suppress(KeyError): - self.loop_alert_channels.remove(FirstHash(channel)) - if not self.loop_alert_channels: - self._notifier.cancel() + self.notifier.remove_channel(channel) return True log.debug(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") return False - @tasks.loop() - async def _notifier(self) -> None: - """Post notice of permanently silenced channels to `mod_alerts` periodically.""" - # Wait for 15 minutes between notices with pause at start of loop. - await asyncio.sleep(15*60) - current_iter = self._notifier.current_loop+1 - channels_text = ', '.join( - f"{channel.mention} for {current_iter-start} min" - for channel, start in self.loop_alert_channels - ) - channels_log_text = ', '.join( - f'#{channel} ({channel.id})' for channel, _ in self.loop_alert_channels - ) - log.debug(f"Sending notice with channels: {channels_log_text}") - await self._mod_alerts_channel.send(f"<@&{Roles.moderators}> currently silenced channels: {channels_text}") - - @_notifier.before_loop - async def _log_notifier_start(self) -> None: - log.trace("Starting notifier loop.") - - @_notifier.after_loop - async def _log_notifier_end(self) -> None: - log.trace("Stopping notifier loop.") - # This cannot be static (must have a __func__ attribute). def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" -- cgit v1.2.3 From 8cf851b5daccb8d8c8b520fe34c3fd5dada8505b Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Sun, 8 Mar 2020 19:00:47 -0400 Subject: Refactor token detection to check all potential substrings in message --- bot/cogs/token_remover.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) 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 -- cgit v1.2.3 From 8bf5ce5a831e389cc7af07fc0b4853ea21ec0c71 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Mon, 9 Mar 2020 11:33:38 +0700 Subject: Refactored to use paginator like normal `!tag` - Split `_get_tags_via_content` - introduce `_send_matching_tags` - `_send_matching_tags` will send and paginate like `!tag` - Simplified `is_plural` even more. --- bot/cogs/tags.py | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index e3ade07a9..c6b442912 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -86,7 +86,7 @@ class Tags(Cog): return self._get_suggestions(tag_name) return found - async def _get_tags_via_content(self, check: Callable[[Iterable], bool], keywords: str) -> Optional[Embed]: + async def _get_tags_via_content(self, check: Callable[[Iterable], bool], keywords: str) -> list: """ Search for tags via contents. @@ -112,18 +112,28 @@ class Tags(Cog): 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: - return None + pass elif len(matching_tags) == 1: - return Embed().from_dict(matching_tags[0]['embed']) + await ctx.send(embed=Embed().from_dict(matching_tags[0]['embed'])) else: - is_plural = len(keywords_processed) > 1 or keywords.strip().count(' ') > 0 + 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]) ) - embed.set_footer(text=f"Keyword{'s' * is_plural} used: {keywords}"[:1024]) - return embed + await LinePaginator.paginate( + sorted(f"**»** {tag['title']}" for tag in matching_tags), + ctx, + embed, + footer_text="To show a tag, type !tags .", + 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: @@ -137,9 +147,8 @@ class Tags(Cog): Only search for tags that has ALL the keywords. """ - result = await self._get_tags_via_content(all, keywords) - if result: - await ctx.send(embed=result) + 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: @@ -148,9 +157,8 @@ class Tags(Cog): Search for tags that has ANY of the keywords. """ - result = await self._get_tags_via_content(any, keywords or 'any') - if result: - await ctx.send(embed=result) + 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: -- cgit v1.2.3 From 4ffdb13172251e77c727cd64ce7b18da0844e966 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Mon, 9 Mar 2020 16:37:20 +1000 Subject: Implement vote command. The vote command takes a given list of options and generates a simple message and corresponding reactions for each so members can quickly take a vote on a subject during in-server discussions and meetings. --- bot/cogs/utils.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 8ea972145..c5eaa547b 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -257,6 +257,24 @@ class Utils(Cog): 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) > 20: + raise BadArgument("I can only handle 20 options!") + + options = {chr(i): f"{chr(i)} - {v}" for i, v in enumerate(options, start=127462)} + 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.""" -- cgit v1.2.3 From 49dd708625a7edff567efbd538abf4c170d59393 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Mon, 9 Mar 2020 17:27:30 +1000 Subject: Check lower bound for vote options. If the vote command receives less than 2 options, it's not being used for it's intended usage and is considered a user input error. --- bot/cogs/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index c5eaa547b..2003eb350 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -266,6 +266,8 @@ class Utils(Cog): 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!") -- cgit v1.2.3 From 82b7d0c4398020616a2d26e3785ccae4b980d056 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Mon, 9 Mar 2020 17:29:48 +1000 Subject: Disambiguate codepoint value. The usage of 127462 as a unicode start point isn't super clear for other devs coming across the code in future, so assigning it to a nicely named variable with an accompanying inline comment should help make things clearer. --- bot/cogs/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 2003eb350..024141d62 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -271,7 +271,8 @@ class Utils(Cog): if len(options) > 20: raise BadArgument("I can only handle 20 options!") - options = {chr(i): f"{chr(i)} - {v}" for i, v in enumerate(options, start=127462)} + 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: -- cgit v1.2.3 From f1eb927fb1aa1fffc9f3e03a2987e03361d9f0b9 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Fri, 6 Mar 2020 14:35:17 -0600 Subject: Added BigBrother Helper Methods - Added apply_unwatch() and migrated the code from the unwatch command to it. This will give us more control regarding testing and also determining when unwatches trigger. - Added apply_watch() and migrated the code from the watch command to it. Again, this will assist with testing and could make it easier to automate adding to the watch list if need be. - Added unwatch call to apply_ban. User will only be removed from the watch list if they were permanently banned. They will not be removed if it was only temporary. Signed-off-by: Daniel Brown --- bot/cogs/moderation/infractions.py | 13 ++++++++++++- bot/cogs/watchchannels/bigbrother.py | 22 ++++++++++++++++------ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 9ea17b2b3..9bab38e23 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -67,7 +67,7 @@ class Infractions(InfractionScheduler, commands.Cog): @command() async def ban(self, ctx: Context, user: FetchedMember, *, reason: str = None) -> None: - """Permanently ban a user for the given reason.""" + """Permanently ban a user for the given reason. Also removes them from the BigBrother watch list.""" await self.apply_ban(ctx, user, reason) # endregion @@ -243,6 +243,17 @@ class Infractions(InfractionScheduler, commands.Cog): action = ctx.guild.ban(user, reason=reason, delete_message_days=0) await self.apply_infraction(ctx, infraction, user, action) + # Remove perma banned users from the watch list + if 'expires_at' not in kwargs: + bb_cog = self.bot.get_cog("BigBrother") + if bb_cog: + await bb_cog.apply_unwatch( + ctx, + user, + "User has been permanently banned from the server. Automatically removed.", + banned=True + ) + # endregion # region: Base pardon functions diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index c601e0d4d..75b66839e 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -52,6 +52,16 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): A `reason` for adding the user to Big Brother is required and will be displayed in the header when relaying messages of this user to the watchchannel. """ + await self.apply_watch(ctx, user, reason) + + @bigbrother_group.command(name='unwatch', aliases=('uw',)) + @with_role(*MODERATION_ROLES) + async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """Stop relaying messages by the given `user`.""" + await self.apply_unwatch(ctx, user, reason) + + async def apply_watch(self, ctx: Context, user: FetchedMember, reason: str) -> None: + """Handles adding a user to the watch list.""" if user.bot: await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") return @@ -90,10 +100,8 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await ctx.send(msg) - @bigbrother_group.command(name='unwatch', aliases=('uw',)) - @with_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Stop relaying messages by the given `user`.""" + async def apply_unwatch(self, ctx: Context, user: FetchedMember, reason: str, banned: bool = False) -> None: + """Handles the actual user removal from the watch list.""" active_watches = await self.bot.api_client.get( self.api_endpoint, params=ChainMap( @@ -111,8 +119,10 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await post_infraction(ctx, user, 'watch', f"Unwatched: {reason}", hidden=True, active=False) - await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed.") + if not banned: # Prevents a message being sent to the channel if part of a permanent ban + await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed.") self._remove_user(user.id) else: - await ctx.send(":x: The specified user is currently not being watched.") + if not banned: # Prevents a message being sent to the channel if part of a permanent ban + await ctx.send(":x: The specified user is currently not being watched.") -- cgit v1.2.3 From ee94c38063981ee6770c1d263eab9c0d2e178380 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 9 Mar 2020 20:41:55 +0100 Subject: Use `patch.object` instead of patch with direct `return_value`. --- tests/bot/cogs/moderation/test_silence.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 17420ce7d..53b3fd388 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -1,6 +1,5 @@ import asyncio import unittest -from functools import partial from unittest import mock from bot.cogs.moderation.silence import FirstHash, Silence @@ -50,18 +49,12 @@ class SilenceTests(unittest.TestCase): result_message=result_message, starting_unsilenced_state=_silence_patch_return ): - with mock.patch( - "bot.cogs.moderation.silence.Silence._silence", - new_callable=partial(mock.AsyncMock, return_value=_silence_patch_return) - ): + with mock.patch.object(self.cog, "_silence", return_value=_silence_patch_return): asyncio.run(self.cog.silence.callback(*silence_call_args)) self.ctx.send.call_args.assert_called_once_with(result_message) def test_unsilence_sent_correct_discord_message(self): """Check if proper message was sent to `alert_chanel`.""" - with mock.patch( - "bot.cogs.moderation.silence.Silence._unsilence", - new_callable=partial(mock.AsyncMock, return_value=True) - ): + with mock.patch.object(self.cog, "_unsilence", return_value=True): asyncio.run(self.cog.unsilence.callback(self.cog, self.ctx)) self.ctx.channel.send.call_args.assert_called_once_with(f"{Emojis.check_mark} Unsilenced #channel.") -- cgit v1.2.3 From 60814ee9270d4c550047478bf8d4a179d7351696 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 13:09:08 -0700 Subject: Cog tests: create boilerplate for command name tests --- tests/bot/cogs/test_cogs.py | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 tests/bot/cogs/test_cogs.py diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py new file mode 100644 index 000000000..6f5d07030 --- /dev/null +++ b/tests/bot/cogs/test_cogs.py @@ -0,0 +1,7 @@ +"""Test suite for general tests which apply to all cogs.""" + +import unittest + + +class CommandNameTests(unittest.TestCase): + """Tests for shadowing command names and aliases.""" -- cgit v1.2.3 From d31f7e3f4a4876d51119d5875afa9221b14b285e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 13:10:21 -0700 Subject: Cog tests: add a function to get all commands For tests, ideally creating instances of cogs should be avoided to avoid extra code execution. This function was copied over from discord.py because their function is not a static method, though it still works as one. It was probably just a design decision on their part to not make it static. --- tests/bot/cogs/test_cogs.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index 6f5d07030..b128ca123 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -1,7 +1,19 @@ """Test suite for general tests which apply to all cogs.""" +import typing as t import unittest +from discord.ext import commands + class CommandNameTests(unittest.TestCase): """Tests for shadowing command names and aliases.""" + + @staticmethod + def walk_commands(cog: commands.Cog) -> t.Iterator[commands.Command]: + """An iterator that recursively walks through `cog`'s commands and subcommands.""" + for command in cog.__cog_commands__: + if command.parent is None: + yield command + if isinstance(command, commands.GroupMixin): + yield from command.walk_commands() -- cgit v1.2.3 From d9bf06e7b916a7214f00b43cb08b582485f86781 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 10 Mar 2020 00:38:43 +0100 Subject: Retain previous channel overwrites. Previously silencing a channel reset all overwrites excluding `send_messages` and unsilencing them removed all overwrites. This is prevented by getting the current overwrite and applying it with only send_messages changed. --- bot/cogs/moderation/silence.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index e12b6c606..626c1ecfb 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -128,10 +128,14 @@ class Silence(commands.Cog): If `persistent` is `True` add `channel` to notifier. `duration` is only used for logging; if None is passed `persistent` should be True to not log None. """ - if channel.overwrites_for(self._verified_role).send_messages is False: + current_overwrite = channel.overwrites_for(self._verified_role) + if current_overwrite.send_messages is False: log.debug(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.") return False - await channel.set_permissions(self._verified_role, overwrite=PermissionOverwrite(send_messages=False)) + await channel.set_permissions( + self._verified_role, + overwrite=PermissionOverwrite(**dict(current_overwrite, send_messages=False)) + ) if persistent: log.debug(f"Silenced #{channel} ({channel.id}) indefinitely.") self.notifier.add_channel(channel) @@ -147,8 +151,12 @@ class Silence(commands.Cog): Check if `channel` is silenced through a `PermissionOverwrite`, if it is unsilence it and remove it from the notifier. """ - if channel.overwrites_for(self._verified_role).send_messages is False: - await channel.set_permissions(self._verified_role, overwrite=None) + current_overwrite = channel.overwrites_for(self._verified_role) + if current_overwrite.send_messages is False: + await channel.set_permissions( + self._verified_role, + overwrite=PermissionOverwrite(**dict(current_overwrite, send_messages=True)) + ) log.debug(f"Unsilenced channel #{channel} ({channel.id}).") self.notifier.remove_channel(channel) return True -- cgit v1.2.3 From 8e98a48420be718973b9a5b8aec83d4133ddc6e9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 3 Mar 2020 08:32:57 -0800 Subject: CI: make env vars used for coverage into pipeline variables Makes the script for the coverage step cleaner. --- azure-pipelines.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 16d1b7a2a..d97a13659 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -14,6 +14,12 @@ jobs: variables: PIP_CACHE_DIR: ".cache/pip" PRE_COMMIT_HOME: $(Pipeline.Workspace)/pre-commit-cache + BOT_API_KEY: foo + BOT_SENTRY_DSN: blah + BOT_TOKEN: bar + REDDIT_CLIENT_ID: spam + REDDIT_SECRET: ham + WOLFRAM_API_KEY: baz steps: - task: UsePythonVersion@0 @@ -50,7 +56,7 @@ jobs: - 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 + - script: coverage run -m xmlrunner displayName: Run tests - script: coverage report -m && coverage xml -o coverage.xml -- cgit v1.2.3 From 5e315135e8b33658c19de7c249adefe33f79443f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 2 Mar 2020 19:48:49 -0800 Subject: CI: cache Python dependencies Reduces frequency of using pipenv to install dependencies in CI. Works by caching the entire Python directory. Only a full cache hit will skip the pipenv steps; a partial cache hit will still be followed by using pipenv to install from the pipfiles. * Disable pip cache --- azure-pipelines.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index d97a13659..d7cf03aae 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -1,6 +1,7 @@ # https://aka.ms/yaml variables: + PIP_NO_CACHE_DIR: false PIPENV_HIDE_EMOJIS: 1 PIPENV_IGNORE_VIRTUALENVS: 1 PIPENV_NOSPIN: 1 @@ -12,7 +13,6 @@ jobs: vmImage: ubuntu-18.04 variables: - PIP_CACHE_DIR: ".cache/pip" PRE_COMMIT_HOME: $(Pipeline.Workspace)/pre-commit-cache BOT_API_KEY: foo BOT_SENTRY_DSN: blah @@ -29,11 +29,24 @@ jobs: versionSpec: '3.8.x' addToPath: true + - task: Cache@2 + displayName: 'Restore Python environment' + inputs: + key: python | $(Agent.OS) | "$(PythonVersion.pythonLocation)" | ./Pipfile | ./Pipfile.lock + restoreKeys: | + python | "$(PythonVersion.pythonLocation)" | ./Pipfile.lock + python | "$(PythonVersion.pythonLocation)" | ./Pipfile + python | "$(PythonVersion.pythonLocation)" + cacheHitVar: PY_ENV_RESTORED + path: $(PythonVersion.pythonLocation) + - script: pip install pipenv displayName: 'Install pipenv' + condition: and(succeeded(), ne(variables.PY_ENV_RESTORED, 'true')) - script: pipenv install --dev --deploy --system displayName: 'Install project using pipenv' + condition: and(succeeded(), ne(variables.PY_ENV_RESTORED, 'true')) # Create an executable shell script which replaces the original pipenv binary. # The shell script ignores the first argument and executes the rest of the args as a command. -- cgit v1.2.3 From 952e46350bfd95521713f36fa0afcd4eb613026a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 17:33:17 -0700 Subject: CI: cache the Python user base dir --- azure-pipelines.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index d7cf03aae..45c6699b5 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -5,6 +5,8 @@ variables: PIPENV_HIDE_EMOJIS: 1 PIPENV_IGNORE_VIRTUALENVS: 1 PIPENV_NOSPIN: 1 + PRE_COMMIT_HOME: $(Pipeline.Workspace)/pre-commit-cache + PYTHONUSERBASE: $(Pipeline.Workspace)/py-user-base jobs: - job: test @@ -13,7 +15,6 @@ jobs: vmImage: ubuntu-18.04 variables: - PRE_COMMIT_HOME: $(Pipeline.Workspace)/pre-commit-cache BOT_API_KEY: foo BOT_SENTRY_DSN: blah BOT_TOKEN: bar @@ -38,13 +39,14 @@ jobs: python | "$(PythonVersion.pythonLocation)" | ./Pipfile python | "$(PythonVersion.pythonLocation)" cacheHitVar: PY_ENV_RESTORED - path: $(PythonVersion.pythonLocation) + path: $(PYTHONUSERBASE) - script: pip install pipenv displayName: 'Install pipenv' condition: and(succeeded(), ne(variables.PY_ENV_RESTORED, 'true')) - - script: pipenv install --dev --deploy --system + # PIP_USER=1 will install packages to the user site. + - script: export PIP_USER=1; pipenv install --dev --deploy --system displayName: 'Install project using pipenv' condition: and(succeeded(), ne(variables.PY_ENV_RESTORED, 'true')) -- cgit v1.2.3 From e9d58e4e021d840a569c382a5494faa52e2ef260 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 17:36:55 -0700 Subject: CI: prepend py user base to PATH --- azure-pipelines.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 45c6699b5..079530cc8 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -50,6 +50,9 @@ jobs: displayName: 'Install project using pipenv' condition: and(succeeded(), ne(variables.PY_ENV_RESTORED, 'true')) + - script: echo '##vso[task.prependpath]$(PYTHONUSERBASE)/bin' + displayName: 'Prepend PATH' + # Create an executable shell script which replaces the original pipenv binary. # The shell script ignores the first argument and executes the rest of the args as a command. # It makes the `pipenv run flake8` command in the pre-commit hook work by circumventing -- cgit v1.2.3 From 413f09fc4f200cab75ca64ec69cf82a125246dc0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 17:58:54 -0700 Subject: CI: invalidate caches --- azure-pipelines.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 079530cc8..3b0a23064 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -33,11 +33,11 @@ jobs: - task: Cache@2 displayName: 'Restore Python environment' inputs: - key: python | $(Agent.OS) | "$(PythonVersion.pythonLocation)" | ./Pipfile | ./Pipfile.lock + key: python | $(Agent.OS) | "$(PythonVersion.pythonLocation)" | 0 | ./Pipfile | ./Pipfile.lock restoreKeys: | - python | "$(PythonVersion.pythonLocation)" | ./Pipfile.lock - python | "$(PythonVersion.pythonLocation)" | ./Pipfile - python | "$(PythonVersion.pythonLocation)" + python | "$(PythonVersion.pythonLocation)" | 0 | ./Pipfile.lock + python | "$(PythonVersion.pythonLocation)" | 0 | ./Pipfile + python | "$(PythonVersion.pythonLocation)" | 0 cacheHitVar: PY_ENV_RESTORED path: $(PYTHONUSERBASE) @@ -66,9 +66,9 @@ jobs: - task: Cache@2 displayName: 'Restore pre-commit environment' inputs: - key: pre-commit | "$(PythonVersion.pythonLocation)" | .pre-commit-config.yaml + key: pre-commit | "$(PythonVersion.pythonLocation)" | 0 | .pre-commit-config.yaml restoreKeys: | - pre-commit | "$(PythonVersion.pythonLocation)" + pre-commit | "$(PythonVersion.pythonLocation)" | 0 path: $(PRE_COMMIT_HOME) - script: pre-commit run --all-files -- cgit v1.2.3 From fe3236c2e450e17f98454bf38b3b1f77298c78be Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 18:53:53 -0700 Subject: CI: install pipenv to user site Some of pipenv's dependencies overlap with dependencies in the Pipfile. When installing from the Pipfile, any dependencies already present in the global site will not be installed again to the user site, and thus will not be cached. Therefore, pipenv is installed to the user site to ensure all dependencies get cached. * Move PATH prepend step before pipenv invocation --- azure-pipelines.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 3b0a23064..9660b2621 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -2,6 +2,7 @@ variables: PIP_NO_CACHE_DIR: false + PIP_USER: 1 PIPENV_HIDE_EMOJIS: 1 PIPENV_IGNORE_VIRTUALENVS: 1 PIPENV_NOSPIN: 1 @@ -41,18 +42,17 @@ jobs: cacheHitVar: PY_ENV_RESTORED path: $(PYTHONUSERBASE) + - script: echo '##vso[task.prependpath]$(PYTHONUSERBASE)/bin' + displayName: 'Prepend PATH' + - script: pip install pipenv displayName: 'Install pipenv' condition: and(succeeded(), ne(variables.PY_ENV_RESTORED, 'true')) - # PIP_USER=1 will install packages to the user site. - - script: export PIP_USER=1; pipenv install --dev --deploy --system + - script: pipenv install --dev --deploy --system displayName: 'Install project using pipenv' condition: and(succeeded(), ne(variables.PY_ENV_RESTORED, 'true')) - - script: echo '##vso[task.prependpath]$(PYTHONUSERBASE)/bin' - displayName: 'Prepend PATH' - # Create an executable shell script which replaces the original pipenv binary. # The shell script ignores the first argument and executes the rest of the args as a command. # It makes the `pipenv run flake8` command in the pre-commit hook work by circumventing -- cgit v1.2.3 From f6ed0362300227c35477f4a01263b496464f9d2d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 23:04:30 -0700 Subject: CI: don't do a user install for pre-commit venv Prevents the following error: Can not perform a '--user' install. User site-packages are not visible in this virtualenv. --- azure-pipelines.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 9660b2621..3557410c6 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -71,7 +71,8 @@ jobs: pre-commit | "$(PythonVersion.pythonLocation)" | 0 path: $(PRE_COMMIT_HOME) - - script: pre-commit run --all-files + # pre-commit's venv doesn't allow user installs - not that they're really needed anyway. + - script: export PIP_USER=0; pre-commit run --all-files displayName: 'Run pre-commit hooks' - script: coverage run -m xmlrunner -- cgit v1.2.3 From 90a2e14abb898f39000cf11cfd26f0d89abb4800 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 10 Mar 2020 19:43:57 +0100 Subject: Remove `channel` arg from commands. --- bot/cogs/moderation/silence.py | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 626c1ecfb..0fe720882 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -5,7 +5,7 @@ from typing import Optional from discord import PermissionOverwrite, TextChannel from discord.ext import commands, tasks -from discord.ext.commands import Context, TextChannelConverter +from discord.ext.commands import Context from bot.bot import Bot from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles @@ -84,42 +84,32 @@ class Silence(commands.Cog): self.notifier = SilenceNotifier(self._mod_log_channel) @commands.command(aliases=("hush",)) - async def silence( - self, - ctx: Context, - duration: HushDurationConverter = 10, - channel: TextChannelConverter = None - ) -> None: + async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None: """ Silence `channel` for `duration` minutes or `"forever"`. If duration is forever, start a notifier loop that triggers every 15 minutes. """ - channel = channel or ctx.channel - - if not await self._silence(channel, persistent=(duration is None), duration=duration): - await ctx.send(f"{Emojis.cross_mark} {channel.mention} is already silenced.") + if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): + await ctx.send(f"{Emojis.cross_mark} {ctx.channel.mention} is already silenced.") return if duration is None: - await ctx.send(f"{Emojis.check_mark} {channel.mention} silenced indefinitely.") + await ctx.send(f"{Emojis.check_mark} {ctx.channel.mention} silenced indefinitely.") return - await ctx.send(f"{Emojis.check_mark} {channel.mention} silenced for {duration} minute(s).") + await ctx.send(f"{Emojis.check_mark} {ctx.channel.mention} silenced for {duration} minute(s).") await asyncio.sleep(duration*60) - await ctx.invoke(self.unsilence, channel=channel) + await ctx.invoke(self.unsilence) @commands.command(aliases=("unhush",)) - async def unsilence(self, ctx: Context, channel: TextChannelConverter = None) -> None: + async def unsilence(self, ctx: Context) -> None: """ Unsilence `channel`. Unsilence a previously silenced `channel` and remove it from indefinitely muted channels notice if applicable. """ - channel = channel or ctx.channel - alert_channel = self._mod_log_channel if ctx.invoked_with == "hush" else ctx.channel - - if await self._unsilence(channel): - await alert_channel.send(f"{Emojis.check_mark} Unsilenced {channel.mention}.") + if await self._unsilence(ctx.channel): + await ctx.send(f"{Emojis.check_mark} Unsilenced {ctx.channel.mention}.") async def _silence(self, channel: TextChannel, persistent: bool, duration: Optional[int]) -> bool: """ -- cgit v1.2.3 From 39b2319bff31a7b3e2cb17154bcbdad0a0e71fc7 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 10 Mar 2020 23:38:53 +0100 Subject: Add alert with silenced channels on `cog_unload`. --- bot/cogs/moderation/silence.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 0fe720882..76c5a171d 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -72,6 +72,7 @@ class Silence(commands.Cog): def __init__(self, bot: Bot): self.bot = bot + self.muted_channels = set() self.bot.loop.create_task(self._get_instance_vars()) async def _get_instance_vars(self) -> None: @@ -131,6 +132,7 @@ class Silence(commands.Cog): self.notifier.add_channel(channel) return True + self.muted_channels.add(channel) log.debug(f"Silenced #{channel} ({channel.id}) for {duration} minute(s).") return True @@ -149,10 +151,19 @@ class Silence(commands.Cog): ) log.debug(f"Unsilenced channel #{channel} ({channel.id}).") self.notifier.remove_channel(channel) + with suppress(KeyError): + self.muted_channels.remove(channel) return True log.debug(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") return False + def cog_unload(self) -> None: + """Send alert with silenced channels on unload.""" + if self.muted_channels: + channels_string = ''.join(channel.mention for channel in self.muted_channels) + message = f"<@&{Roles.moderators}> channels left silenced on cog unload: {channels_string}" + asyncio.create_task(self._mod_alerts_channel.send(message)) + # This cannot be static (must have a __func__ attribute). def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" -- cgit v1.2.3 From 85c439fbf78f59ff314f4f9daef1467d486709c3 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 00:01:58 +0100 Subject: Remove unnecessary args from test cases. Needless call args which were constant were kept in the test cases, resulting in redundant code, the args were moved directly into the function call. --- tests/bot/cogs/moderation/test_silence.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 53b3fd388..1341911d5 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -39,18 +39,18 @@ class SilenceTests(unittest.TestCase): def test_silence_sent_correct_discord_message(self): """Check if proper message was sent when called with duration in channel with previous state.""" test_cases = ( - ((self.cog, self.ctx, 0.0001), f"{Emojis.check_mark} #channel silenced for 0.0001 minute(s).", True,), - ((self.cog, self.ctx, None), f"{Emojis.check_mark} #channel silenced indefinitely.", True,), - ((self.cog, self.ctx, 5), f"{Emojis.cross_mark} #channel is already silenced.", False,), + (0.0001, f"{Emojis.check_mark} #channel silenced for 0.0001 minute(s).", True,), + (None, f"{Emojis.check_mark} #channel silenced indefinitely.", True,), + (5, f"{Emojis.cross_mark} #channel is already silenced.", False,), ) - for silence_call_args, result_message, _silence_patch_return in test_cases: + for duration, result_message, _silence_patch_return in test_cases: with self.subTest( - silence_duration=silence_call_args[-1], + silence_duration=duration, result_message=result_message, starting_unsilenced_state=_silence_patch_return ): with mock.patch.object(self.cog, "_silence", return_value=_silence_patch_return): - asyncio.run(self.cog.silence.callback(*silence_call_args)) + asyncio.run(self.cog.silence.callback(self.cog, self.ctx, duration)) self.ctx.send.call_args.assert_called_once_with(result_message) def test_unsilence_sent_correct_discord_message(self): -- cgit v1.2.3 From adaf456607ba2f2724c6fd34308cd170c81aa651 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 00:56:31 +0100 Subject: Remove channel mentions from output discord messages. With the removal of the channel args, it's no longer necessary to mention the channel in the command output. Tests adjusted accordingly --- bot/cogs/moderation/silence.py | 8 ++++---- tests/bot/cogs/moderation/test_silence.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 76c5a171d..68cad4062 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -92,13 +92,13 @@ class Silence(commands.Cog): If duration is forever, start a notifier loop that triggers every 15 minutes. """ if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): - await ctx.send(f"{Emojis.cross_mark} {ctx.channel.mention} is already silenced.") + await ctx.send(f"{Emojis.cross_mark} current channel is already silenced.") return if duration is None: - await ctx.send(f"{Emojis.check_mark} {ctx.channel.mention} silenced indefinitely.") + await ctx.send(f"{Emojis.check_mark} silenced current channel indefinitely.") return - await ctx.send(f"{Emojis.check_mark} {ctx.channel.mention} silenced for {duration} minute(s).") + await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).") await asyncio.sleep(duration*60) await ctx.invoke(self.unsilence) @@ -110,7 +110,7 @@ class Silence(commands.Cog): Unsilence a previously silenced `channel` and remove it from indefinitely muted channels notice if applicable. """ if await self._unsilence(ctx.channel): - await ctx.send(f"{Emojis.check_mark} Unsilenced {ctx.channel.mention}.") + await ctx.send(f"{Emojis.check_mark} unsilenced current channel.") async def _silence(self, channel: TextChannel, persistent: bool, duration: Optional[int]) -> bool: """ diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 1341911d5..6da374a8f 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -39,9 +39,9 @@ class SilenceTests(unittest.TestCase): def test_silence_sent_correct_discord_message(self): """Check if proper message was sent when called with duration in channel with previous state.""" test_cases = ( - (0.0001, f"{Emojis.check_mark} #channel silenced for 0.0001 minute(s).", True,), - (None, f"{Emojis.check_mark} #channel silenced indefinitely.", True,), - (5, f"{Emojis.cross_mark} #channel is already silenced.", False,), + (0.0001, f"{Emojis.check_mark} silenced current channel for 0.0001 minute(s).", True,), + (None, f"{Emojis.check_mark} silenced current channel indefinitely.", True,), + (5, f"{Emojis.cross_mark} current channel is already silenced.", False,), ) for duration, result_message, _silence_patch_return in test_cases: with self.subTest( @@ -57,4 +57,4 @@ class SilenceTests(unittest.TestCase): """Check if proper message was sent to `alert_chanel`.""" with mock.patch.object(self.cog, "_unsilence", return_value=True): asyncio.run(self.cog.unsilence.callback(self.cog, self.ctx)) - self.ctx.channel.send.call_args.assert_called_once_with(f"{Emojis.check_mark} Unsilenced #channel.") + self.ctx.send.call_args.assert_called_once_with(f"{Emojis.check_mark} unsilenced current channel.") -- cgit v1.2.3 From a24ffb3b53ca2043afe4d428e1456b6979c1f888 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 01:21:54 +0100 Subject: Move adding of channel to `muted_channels` up. Before the channel was not added if `persistent` was `True`. --- bot/cogs/moderation/silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 68cad4062..4153b3439 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -127,12 +127,12 @@ class Silence(commands.Cog): self._verified_role, overwrite=PermissionOverwrite(**dict(current_overwrite, send_messages=False)) ) + self.muted_channels.add(channel) if persistent: log.debug(f"Silenced #{channel} ({channel.id}) indefinitely.") self.notifier.add_channel(channel) return True - self.muted_channels.add(channel) log.debug(f"Silenced #{channel} ({channel.id}) for {duration} minute(s).") return True -- cgit v1.2.3 From 68d43946d1dc6393a4f7b8b4812b5c4787842c12 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 01:28:26 +0100 Subject: Add test for `_silence` method. --- tests/bot/cogs/moderation/test_silence.py | 35 ++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 6da374a8f..6a75db2a0 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -1,10 +1,11 @@ import asyncio import unittest from unittest import mock +from unittest.mock import Mock from bot.cogs.moderation.silence import FirstHash, Silence from bot.constants import Emojis -from tests.helpers import MockBot, MockContext +from tests.helpers import MockBot, MockContext, MockTextChannel class FirstHashTests(unittest.TestCase): @@ -35,6 +36,7 @@ class SilenceTests(unittest.TestCase): self.bot = MockBot() self.cog = Silence(self.bot) self.ctx = MockContext() + self.cog._verified_role = None def test_silence_sent_correct_discord_message(self): """Check if proper message was sent when called with duration in channel with previous state.""" @@ -58,3 +60,34 @@ class SilenceTests(unittest.TestCase): with mock.patch.object(self.cog, "_unsilence", return_value=True): asyncio.run(self.cog.unsilence.callback(self.cog, self.ctx)) self.ctx.send.call_args.assert_called_once_with(f"{Emojis.check_mark} unsilenced current channel.") + + def test_silence_private_for_false(self): + """Permissions are not set and `False` is returned in an already silenced channel.""" + perm_overwrite = Mock(send_messages=False) + channel = Mock(overwrites_for=Mock(return_value=perm_overwrite)) + + self.assertFalse(asyncio.run(self.cog._silence(channel, True, None))) + channel.set_permissions.assert_not_called() + + def test_silence_private_silenced_channel(self): + """Channel had `send_message` permissions revoked and was added to `muted_channels`.""" + channel = MockTextChannel() + muted_channels = Mock() + with mock.patch.object(self.cog, "muted_channels", new=muted_channels, create=True): + self.assertTrue(asyncio.run(self.cog._silence(channel, False, None))) + channel.set_permissions.assert_called_once() + self.assertFalse(channel.set_permissions.call_args.kwargs['overwrite'].send_messages) + muted_channels.add.call_args.assert_called_once_with(channel) + + def test_silence_private_notifier(self): + """Channel should be added to notifier with `persistent` set to `True`, and the other way around.""" + channel = MockTextChannel() + with mock.patch.object(self.cog, "notifier", create=True): + with self.subTest(persistent=True): + asyncio.run(self.cog._silence(channel, True, None)) + self.cog.notifier.add_channel.assert_called_once() + + with mock.patch.object(self.cog, "notifier", create=True): + with self.subTest(persistent=False): + asyncio.run(self.cog._silence(channel, False, None)) + self.cog.notifier.add_channel.assert_not_called() -- cgit v1.2.3 From fef8c8e8504d8431ae7cad23128733d0b9039c7a Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 02:31:36 +0100 Subject: Use async test case. This allows us to use coroutines with await directly instead of asyncio.run --- tests/bot/cogs/moderation/test_silence.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 6a75db2a0..33ff78ca6 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -1,4 +1,3 @@ -import asyncio import unittest from unittest import mock from unittest.mock import Mock @@ -30,15 +29,14 @@ class FirstHashTests(unittest.TestCase): self.assertTrue(tuple1 == tuple2) -class SilenceTests(unittest.TestCase): +class SilenceTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: - self.bot = MockBot() self.cog = Silence(self.bot) self.ctx = MockContext() self.cog._verified_role = None - def test_silence_sent_correct_discord_message(self): + async def test_silence_sent_correct_discord_message(self): """Check if proper message was sent when called with duration in channel with previous state.""" test_cases = ( (0.0001, f"{Emojis.check_mark} silenced current channel for 0.0001 minute(s).", True,), @@ -52,42 +50,42 @@ class SilenceTests(unittest.TestCase): starting_unsilenced_state=_silence_patch_return ): with mock.patch.object(self.cog, "_silence", return_value=_silence_patch_return): - asyncio.run(self.cog.silence.callback(self.cog, self.ctx, duration)) + await self.cog.silence.callback(self.cog, self.ctx, duration) self.ctx.send.call_args.assert_called_once_with(result_message) - def test_unsilence_sent_correct_discord_message(self): + async def test_unsilence_sent_correct_discord_message(self): """Check if proper message was sent to `alert_chanel`.""" with mock.patch.object(self.cog, "_unsilence", return_value=True): - asyncio.run(self.cog.unsilence.callback(self.cog, self.ctx)) + await self.cog.unsilence.callback(self.cog, self.ctx) self.ctx.send.call_args.assert_called_once_with(f"{Emojis.check_mark} unsilenced current channel.") - def test_silence_private_for_false(self): + async def test_silence_private_for_false(self): """Permissions are not set and `False` is returned in an already silenced channel.""" perm_overwrite = Mock(send_messages=False) channel = Mock(overwrites_for=Mock(return_value=perm_overwrite)) - self.assertFalse(asyncio.run(self.cog._silence(channel, True, None))) + self.assertFalse(await self.cog._silence(channel, True, None)) channel.set_permissions.assert_not_called() - def test_silence_private_silenced_channel(self): + async def test_silence_private_silenced_channel(self): """Channel had `send_message` permissions revoked and was added to `muted_channels`.""" channel = MockTextChannel() muted_channels = Mock() with mock.patch.object(self.cog, "muted_channels", new=muted_channels, create=True): - self.assertTrue(asyncio.run(self.cog._silence(channel, False, None))) + self.assertTrue(await self.cog._silence(channel, False, None)) channel.set_permissions.assert_called_once() self.assertFalse(channel.set_permissions.call_args.kwargs['overwrite'].send_messages) muted_channels.add.call_args.assert_called_once_with(channel) - def test_silence_private_notifier(self): + async def test_silence_private_notifier(self): """Channel should be added to notifier with `persistent` set to `True`, and the other way around.""" channel = MockTextChannel() with mock.patch.object(self.cog, "notifier", create=True): with self.subTest(persistent=True): - asyncio.run(self.cog._silence(channel, True, None)) + await self.cog._silence(channel, True, None) self.cog.notifier.add_channel.assert_called_once() with mock.patch.object(self.cog, "notifier", create=True): with self.subTest(persistent=False): - asyncio.run(self.cog._silence(channel, False, None)) + await self.cog._silence(channel, False, None) self.cog.notifier.add_channel.assert_not_called() -- cgit v1.2.3 From c575beccdbe5e4e715a4b11b378dd969a0327191 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 02:47:07 +0100 Subject: Add tests for `_unsilence` --- tests/bot/cogs/moderation/test_silence.py | 34 ++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 33ff78ca6..acfa3ffb8 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -1,6 +1,6 @@ import unittest from unittest import mock -from unittest.mock import Mock +from unittest.mock import MagicMock, Mock from bot.cogs.moderation.silence import FirstHash, Silence from bot.constants import Emojis @@ -89,3 +89,35 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): with self.subTest(persistent=False): await self.cog._silence(channel, False, None) self.cog.notifier.add_channel.assert_not_called() + + async def test_unsilence_private_for_false(self): + """Permissions are not set and `False` is returned in an unsilenced channel.""" + channel = Mock() + self.assertFalse(await self.cog._unsilence(channel)) + channel.set_permissions.assert_not_called() + + async def test_unsilence_private_unsilenced_channel(self): + """Channel had `send_message` permissions restored""" + perm_overwrite = MagicMock(send_messages=False) + channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) + with mock.patch.object(self.cog, "notifier", create=True): + self.assertTrue(await self.cog._unsilence(channel)) + channel.set_permissions.assert_called_once() + self.assertTrue(channel.set_permissions.call_args.kwargs['overwrite'].send_messages) + + async def test_unsilence_private_removed_notifier(self): + """Channel was removed from `notifier` on unsilence.""" + perm_overwrite = MagicMock(send_messages=False) + channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) + with mock.patch.object(self.cog, "notifier", create=True): + await self.cog._unsilence(channel) + self.cog.notifier.remove_channel.call_args.assert_called_once_with(channel) + + async def test_unsilence_private_removed_muted_channel(self): + """Channel was removed from `muted_channels` on unsilence.""" + perm_overwrite = MagicMock(send_messages=False) + channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) + with mock.patch.object(self.cog, "muted_channels", create=True),\ + mock.patch.object(self.cog, "notifier", create=True): # noqa E127 + await self.cog._unsilence(channel) + self.cog.muted_channels.remove.call_args.assert_called_once_with(channel) -- cgit v1.2.3 From a3f07589b215317d6a0fc16d982c3b645fe96151 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 02:54:35 +0100 Subject: Separate tests for permissions and `muted_channels.add` on `_silence`. --- tests/bot/cogs/moderation/test_silence.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index acfa3ffb8..3a513f3a7 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -68,14 +68,11 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel.set_permissions.assert_not_called() async def test_silence_private_silenced_channel(self): - """Channel had `send_message` permissions revoked and was added to `muted_channels`.""" + """Channel had `send_message` permissions revoked.""" channel = MockTextChannel() - muted_channels = Mock() - with mock.patch.object(self.cog, "muted_channels", new=muted_channels, create=True): - self.assertTrue(await self.cog._silence(channel, False, None)) + self.assertTrue(await self.cog._silence(channel, False, None)) channel.set_permissions.assert_called_once() self.assertFalse(channel.set_permissions.call_args.kwargs['overwrite'].send_messages) - muted_channels.add.call_args.assert_called_once_with(channel) async def test_silence_private_notifier(self): """Channel should be added to notifier with `persistent` set to `True`, and the other way around.""" @@ -90,6 +87,12 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._silence(channel, False, None) self.cog.notifier.add_channel.assert_not_called() + async def test_silence_private_removed_muted_channel(self): + channel = MockTextChannel() + with mock.patch.object(self.cog, "muted_channels") as muted_channels: + await self.cog._silence(MockTextChannel(), False, None) + muted_channels.add.call_args.assert_called_once_with(channel) + async def test_unsilence_private_for_false(self): """Permissions are not set and `False` is returned in an unsilenced channel.""" channel = Mock() -- cgit v1.2.3 From c72d31f717ac5e755fe3848c99ebf426fcdf6d8b Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 02:58:09 +0100 Subject: Use patch decorators and assign names from `with` patches. --- tests/bot/cogs/moderation/test_silence.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 3a513f3a7..027508661 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -99,28 +99,28 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(await self.cog._unsilence(channel)) channel.set_permissions.assert_not_called() - async def test_unsilence_private_unsilenced_channel(self): + @mock.patch.object(Silence, "notifier", create=True) + async def test_unsilence_private_unsilenced_channel(self, _): """Channel had `send_message` permissions restored""" perm_overwrite = MagicMock(send_messages=False) channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) - with mock.patch.object(self.cog, "notifier", create=True): - self.assertTrue(await self.cog._unsilence(channel)) + self.assertTrue(await self.cog._unsilence(channel)) channel.set_permissions.assert_called_once() self.assertTrue(channel.set_permissions.call_args.kwargs['overwrite'].send_messages) - async def test_unsilence_private_removed_notifier(self): + @mock.patch.object(Silence, "notifier", create=True) + async def test_unsilence_private_removed_notifier(self, notifier): """Channel was removed from `notifier` on unsilence.""" perm_overwrite = MagicMock(send_messages=False) channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) - with mock.patch.object(self.cog, "notifier", create=True): - await self.cog._unsilence(channel) - self.cog.notifier.remove_channel.call_args.assert_called_once_with(channel) + await self.cog._unsilence(channel) + notifier.remove_channel.call_args.assert_called_once_with(channel) - async def test_unsilence_private_removed_muted_channel(self): + @mock.patch.object(Silence, "notifier", create=True) + async def test_unsilence_private_removed_muted_channel(self, _): """Channel was removed from `muted_channels` on unsilence.""" perm_overwrite = MagicMock(send_messages=False) channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) - with mock.patch.object(self.cog, "muted_channels", create=True),\ - mock.patch.object(self.cog, "notifier", create=True): # noqa E127 + with mock.patch.object(self.cog, "muted_channels") as muted_channels: await self.cog._unsilence(channel) - self.cog.muted_channels.remove.call_args.assert_called_once_with(channel) + muted_channels.remove.call_args.assert_called_once_with(channel) -- cgit v1.2.3 From 44967038f39f4ecd1375fb9edff2b972becb5661 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 14:51:15 +0100 Subject: Add test for `cog_unload`. --- tests/bot/cogs/moderation/test_silence.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 027508661..fc2600f5c 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -3,7 +3,7 @@ from unittest import mock from unittest.mock import MagicMock, Mock from bot.cogs.moderation.silence import FirstHash, Silence -from bot.constants import Emojis +from bot.constants import Emojis, Roles from tests.helpers import MockBot, MockContext, MockTextChannel @@ -124,3 +124,19 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): with mock.patch.object(self.cog, "muted_channels") as muted_channels: await self.cog._unsilence(channel) muted_channels.remove.call_args.assert_called_once_with(channel) + + @mock.patch("bot.cogs.moderation.silence.asyncio") + @mock.patch.object(Silence, "_mod_alerts_channel", create=True) + def test_cog_unload(self, alert_channel, asyncio_mock): + """Task for sending an alert was created with present `muted_channels`.""" + with mock.patch.object(self.cog, "muted_channels"): + self.cog.cog_unload() + asyncio_mock.create_task.call_args.assert_called_once_with( + alert_channel.send(f"<@&{Roles.moderators}> channels left silenced on cog unload: ") + ) + + @mock.patch("bot.cogs.moderation.silence.asyncio") + def test_cog_unload1(self, asyncio_mock): + """No task created with no channels.""" + self.cog.cog_unload() + asyncio_mock.create_task.assert_not_called() -- cgit v1.2.3 From cb9397ba9ef311917629c8904087c1b3c38cc2d3 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 15:26:03 +0100 Subject: Add test for `cog_check`. --- tests/bot/cogs/moderation/test_silence.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index fc2600f5c..eaf897d1d 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -140,3 +140,10 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): """No task created with no channels.""" self.cog.cog_unload() asyncio_mock.create_task.assert_not_called() + + @mock.patch("bot.cogs.moderation.silence.with_role_check") + @mock.patch("bot.cogs.moderation.silence.MODERATION_ROLES", new=(1, 2, 3)) + def test_cog_check(self, role_check): + """Role check is called with `MODERATION_ROLES`""" + self.cog.cog_check(self.ctx) + role_check.assert_called_once_with(self.ctx, *(1, 2, 3)) -- cgit v1.2.3 From fbee48ee04dc6b44f97f229549c62cbfd5cef615 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 15:32:06 +0100 Subject: Fix erroneous `assert_called_once_with` calls. `assert_called_once_with` was being tested on call_args which always reported success.st. --- tests/bot/cogs/moderation/test_silence.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index eaf897d1d..4163a9af7 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -51,13 +51,13 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): ): with mock.patch.object(self.cog, "_silence", return_value=_silence_patch_return): await self.cog.silence.callback(self.cog, self.ctx, duration) - self.ctx.send.call_args.assert_called_once_with(result_message) + self.ctx.send.assert_called_once_with(result_message) async def test_unsilence_sent_correct_discord_message(self): """Check if proper message was sent to `alert_chanel`.""" with mock.patch.object(self.cog, "_unsilence", return_value=True): await self.cog.unsilence.callback(self.cog, self.ctx) - self.ctx.send.call_args.assert_called_once_with(f"{Emojis.check_mark} unsilenced current channel.") + self.ctx.send.assert_called_once_with(f"{Emojis.check_mark} unsilenced current channel.") async def test_silence_private_for_false(self): """Permissions are not set and `False` is returned in an already silenced channel.""" @@ -91,7 +91,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel = MockTextChannel() with mock.patch.object(self.cog, "muted_channels") as muted_channels: await self.cog._silence(MockTextChannel(), False, None) - muted_channels.add.call_args.assert_called_once_with(channel) + muted_channels.add.assert_called_once_with(channel) async def test_unsilence_private_for_false(self): """Permissions are not set and `False` is returned in an unsilenced channel.""" @@ -114,7 +114,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): perm_overwrite = MagicMock(send_messages=False) channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) await self.cog._unsilence(channel) - notifier.remove_channel.call_args.assert_called_once_with(channel) + notifier.remove_channel.assert_called_once_with(channel) @mock.patch.object(Silence, "notifier", create=True) async def test_unsilence_private_removed_muted_channel(self, _): @@ -123,7 +123,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) with mock.patch.object(self.cog, "muted_channels") as muted_channels: await self.cog._unsilence(channel) - muted_channels.remove.call_args.assert_called_once_with(channel) + muted_channels.remove.assert_called_once_with(channel) @mock.patch("bot.cogs.moderation.silence.asyncio") @mock.patch.object(Silence, "_mod_alerts_channel", create=True) @@ -131,9 +131,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): """Task for sending an alert was created with present `muted_channels`.""" with mock.patch.object(self.cog, "muted_channels"): self.cog.cog_unload() - asyncio_mock.create_task.call_args.assert_called_once_with( - alert_channel.send(f"<@&{Roles.moderators}> channels left silenced on cog unload: ") - ) + asyncio_mock.create_task.assert_called_once_with(alert_channel.send()) + alert_channel.send.called_once_with(f"<@&{Roles.moderators}> chandnels left silenced on cog unload: ") @mock.patch("bot.cogs.moderation.silence.asyncio") def test_cog_unload1(self, asyncio_mock): -- cgit v1.2.3 From 64b27e557acf268a19246b2eb80ad6a743df95f4 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 15:32:24 +0100 Subject: Reset `self.ctx` call history after every subtest. --- tests/bot/cogs/moderation/test_silence.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 4163a9af7..ab2f091ec 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -52,6 +52,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): with mock.patch.object(self.cog, "_silence", return_value=_silence_patch_return): await self.cog.silence.callback(self.cog, self.ctx, duration) self.ctx.send.assert_called_once_with(result_message) + self.ctx.reset_mock() async def test_unsilence_sent_correct_discord_message(self): """Check if proper message was sent to `alert_chanel`.""" -- cgit v1.2.3 From 10428d9a456c7bce533cda53100e4c35930211d6 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 15:33:05 +0100 Subject: Pass created channel instead of new object. Creating a new object caused the assert to fail because different objects were used. --- tests/bot/cogs/moderation/test_silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index ab2f091ec..23f8a84ab 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -91,7 +91,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_silence_private_removed_muted_channel(self): channel = MockTextChannel() with mock.patch.object(self.cog, "muted_channels") as muted_channels: - await self.cog._silence(MockTextChannel(), False, None) + await self.cog._silence(channel, False, None) muted_channels.add.assert_called_once_with(channel) async def test_unsilence_private_for_false(self): -- cgit v1.2.3 From b2aa9af7f9f1485aa3ae8ed4d029fd2d72ea17ad Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 16:02:35 +0100 Subject: Add tests for `_get_instance_vars`. --- tests/bot/cogs/moderation/test_silence.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 23f8a84ab..c9aa7d84f 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -3,7 +3,7 @@ from unittest import mock from unittest.mock import MagicMock, Mock from bot.cogs.moderation.silence import FirstHash, Silence -from bot.constants import Emojis, Roles +from bot.constants import Channels, Emojis, Guild, Roles from tests.helpers import MockBot, MockContext, MockTextChannel @@ -36,6 +36,33 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.ctx = MockContext() self.cog._verified_role = None + async def test_instance_vars_got_guild(self): + """Bot got guild after it became available.""" + await self.cog._get_instance_vars() + self.bot.wait_until_guild_available.assert_called_once() + self.bot.get_guild.assert_called_once_with(Guild.id) + + async def test_instance_vars_got_role(self): + """Got `Roles.verified` role from guild.""" + await self.cog._get_instance_vars() + guild = self.bot.get_guild() + guild.get_role.assert_called_once_with(Roles.verified) + + async def test_instance_vars_got_channels(self): + """Got channels from bot.""" + await self.cog._get_instance_vars() + self.bot.get_channel.called_once_with(Channels.mod_alerts) + self.bot.get_channel.called_once_with(Channels.mod_log) + + @mock.patch("bot.cogs.moderation.silence.SilenceNotifier") + async def test_instance_vars_got_notifier(self, notifier): + """Notifier was started with channel.""" + mod_log = MockTextChannel() + self.bot.get_channel.side_effect = (None, mod_log) + await self.cog._get_instance_vars() + notifier.assert_called_once_with(mod_log) + self.bot.get_channel.side_effect = None + async def test_silence_sent_correct_discord_message(self): """Check if proper message was sent when called with duration in channel with previous state.""" test_cases = ( -- cgit v1.2.3 From 8ee70ffe645621a6b97172176afe1ac63261df31 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 16:10:38 +0100 Subject: Create test case for `SilenceNotifier` --- tests/bot/cogs/moderation/test_silence.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index c9aa7d84f..fc7734d45 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -2,7 +2,7 @@ import unittest from unittest import mock from unittest.mock import MagicMock, Mock -from bot.cogs.moderation.silence import FirstHash, Silence +from bot.cogs.moderation.silence import FirstHash, Silence, SilenceNotifier from bot.constants import Channels, Emojis, Guild, Roles from tests.helpers import MockBot, MockContext, MockTextChannel @@ -29,6 +29,12 @@ class FirstHashTests(unittest.TestCase): self.assertTrue(tuple1 == tuple2) +class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.alert_channel = MockTextChannel() + self.notifier = SilenceNotifier(self.alert_channel) + + class SilenceTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.bot = MockBot() -- cgit v1.2.3 From fd75f10f3c8a588bd1763873baad08b8f90d58a3 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 17:09:40 +0100 Subject: Add tests for `add_channel`. --- tests/bot/cogs/moderation/test_silence.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index fc7734d45..be5b8e550 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -33,6 +33,27 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.alert_channel = MockTextChannel() self.notifier = SilenceNotifier(self.alert_channel) + self.notifier.stop = self.notifier_stop_mock = Mock() + self.notifier.start = self.notifier_start_mock = Mock() + self.notifier._current_loop = self.current_loop_mock = Mock() + + def test_add_channel_adds_channel(self): + """Channel in FirstHash with current loop is added to internal set.""" + channel = Mock() + with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: + self.notifier.add_channel(channel) + silenced_channels.add.assert_called_with(FirstHash(channel, self.current_loop_mock)) + + def test_add_channel_starts_loop(self): + """Loop is started if `_silenced_channels` was empty.""" + self.notifier.add_channel(Mock()) + self.notifier_start_mock.assert_called_once() + + def test_add_channel_skips_start_with_channels(self): + """Loop start is not called when `_silenced_channels` is not empty.""" + with mock.patch.object(self.notifier, "_silenced_channels"): + self.notifier.add_channel(Mock()) + self.notifier_start_mock.assert_not_called() class SilenceTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From d9c904164a9e54750ce8ee36535bceacfc4800f5 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 18:06:53 +0100 Subject: Remove `_current_loop` from setup. --- tests/bot/cogs/moderation/test_silence.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index be5b8e550..2e04dc407 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -35,14 +35,13 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): self.notifier = SilenceNotifier(self.alert_channel) self.notifier.stop = self.notifier_stop_mock = Mock() self.notifier.start = self.notifier_start_mock = Mock() - self.notifier._current_loop = self.current_loop_mock = Mock() def test_add_channel_adds_channel(self): """Channel in FirstHash with current loop is added to internal set.""" channel = Mock() with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: self.notifier.add_channel(channel) - silenced_channels.add.assert_called_with(FirstHash(channel, self.current_loop_mock)) + silenced_channels.add.assert_called_with(FirstHash(channel, self.notifier._current_loop)) def test_add_channel_starts_loop(self): """Loop is started if `_silenced_channels` was empty.""" -- cgit v1.2.3 From 4740c0fcdc6da6f164963fb34715e78c5d586cec Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 18:07:14 +0100 Subject: Add tests for `remove_channel`. --- tests/bot/cogs/moderation/test_silence.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 2e04dc407..c52ca2a2a 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -54,6 +54,24 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): self.notifier.add_channel(Mock()) self.notifier_start_mock.assert_not_called() + def test_remove_channel_removes_channel(self): + """Channel in FirstHash is removed from `_silenced_channels`.""" + channel = Mock() + with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: + self.notifier.remove_channel(channel) + silenced_channels.remove.assert_called_with(FirstHash(channel)) + + def test_remove_channel_stops_loop(self): + """Notifier loop is stopped if `_silenced_channels` is empty after remove.""" + with mock.patch.object(self.notifier, "_silenced_channels", __bool__=lambda _: False): + self.notifier.remove_channel(Mock()) + self.notifier_stop_mock.assert_called_once() + + def test_remove_channel_skips_stop_with_channels(self): + """Notifier loop is not stopped if `_silenced_channels` is not empty after remove.""" + self.notifier.remove_channel(Mock()) + self.notifier_stop_mock.assert_not_called() + class SilenceTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: -- cgit v1.2.3 From c392ff64ac4c64ef4baa9834e6e0046e415ce0d9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 11 Mar 2020 10:57:38 -0700 Subject: CI: rename UsePythonVersion task "python" is a shorter and clearer name. --- azure-pipelines.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 3557410c6..16e4489c0 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -26,7 +26,7 @@ jobs: steps: - task: UsePythonVersion@0 displayName: 'Set Python version' - name: PythonVersion + name: python inputs: versionSpec: '3.8.x' addToPath: true @@ -34,11 +34,11 @@ jobs: - task: Cache@2 displayName: 'Restore Python environment' inputs: - key: python | $(Agent.OS) | "$(PythonVersion.pythonLocation)" | 0 | ./Pipfile | ./Pipfile.lock + key: python | $(Agent.OS) | "$(python.pythonLocation)" | 0 | ./Pipfile | ./Pipfile.lock restoreKeys: | - python | "$(PythonVersion.pythonLocation)" | 0 | ./Pipfile.lock - python | "$(PythonVersion.pythonLocation)" | 0 | ./Pipfile - python | "$(PythonVersion.pythonLocation)" | 0 + python | "$(python.pythonLocation)" | 0 | ./Pipfile.lock + python | "$(python.pythonLocation)" | 0 | ./Pipfile + python | "$(python.pythonLocation)" | 0 cacheHitVar: PY_ENV_RESTORED path: $(PYTHONUSERBASE) @@ -59,16 +59,16 @@ jobs: # pipenv entirely, which is too dumb to know it should use the system interpreter rather than # creating a new venv. - script: | - printf '%s\n%s' '#!/bin/bash' '"${@:2}"' > $(PythonVersion.pythonLocation)/bin/pipenv \ - && chmod +x $(PythonVersion.pythonLocation)/bin/pipenv + printf '%s\n%s' '#!/bin/bash' '"${@:2}"' > $(python.pythonLocation)/bin/pipenv \ + && chmod +x $(python.pythonLocation)/bin/pipenv displayName: 'Mock pipenv binary' - task: Cache@2 displayName: 'Restore pre-commit environment' inputs: - key: pre-commit | "$(PythonVersion.pythonLocation)" | 0 | .pre-commit-config.yaml + key: pre-commit | "$(python.pythonLocation)" | 0 | .pre-commit-config.yaml restoreKeys: | - pre-commit | "$(PythonVersion.pythonLocation)" | 0 + pre-commit | "$(python.pythonLocation)" | 0 path: $(PRE_COMMIT_HOME) # pre-commit's venv doesn't allow user installs - not that they're really needed anyway. -- cgit v1.2.3 From 2bcadd209e14e3a119806e069b8ae7150a73c1ba Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 19:12:34 +0100 Subject: Change various logging levels. --- bot/cogs/moderation/silence.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 4153b3439..1c751a4b1 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -41,7 +41,7 @@ class SilenceNotifier(tasks.Loop): """Add channel to `_silenced_channels` and start loop if not launched.""" if not self._silenced_channels: self.start() - log.trace("Starting notifier loop.") + log.info("Starting notifier loop.") self._silenced_channels.add(FirstHash(channel, self._current_loop)) def remove_channel(self, channel: TextChannel) -> None: @@ -50,7 +50,7 @@ class SilenceNotifier(tasks.Loop): self._silenced_channels.remove(FirstHash(channel)) if not self._silenced_channels: self.stop() - log.trace("Stopping notifier loop.") + log.info("Stopping notifier loop.") async def _notifier(self) -> None: """Post notice of `_silenced_channels` with their silenced duration to `_alert_channel` periodically.""" @@ -121,7 +121,7 @@ class Silence(commands.Cog): """ current_overwrite = channel.overwrites_for(self._verified_role) if current_overwrite.send_messages is False: - log.debug(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.") + log.info(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.") return False await channel.set_permissions( self._verified_role, @@ -129,11 +129,11 @@ class Silence(commands.Cog): ) self.muted_channels.add(channel) if persistent: - log.debug(f"Silenced #{channel} ({channel.id}) indefinitely.") + log.info(f"Silenced #{channel} ({channel.id}) indefinitely.") self.notifier.add_channel(channel) return True - log.debug(f"Silenced #{channel} ({channel.id}) for {duration} minute(s).") + log.info(f"Silenced #{channel} ({channel.id}) for {duration} minute(s).") return True async def _unsilence(self, channel: TextChannel) -> bool: @@ -149,12 +149,12 @@ class Silence(commands.Cog): self._verified_role, overwrite=PermissionOverwrite(**dict(current_overwrite, send_messages=True)) ) - log.debug(f"Unsilenced channel #{channel} ({channel.id}).") + log.info(f"Unsilenced channel #{channel} ({channel.id}).") self.notifier.remove_channel(channel) with suppress(KeyError): self.muted_channels.remove(channel) return True - log.debug(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") + log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") return False def cog_unload(self) -> None: -- cgit v1.2.3 From d40a8688bb1e9cf5aa33d7b9fbc5272417eb1c81 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 21:13:43 +0100 Subject: Add logging to commands. --- bot/cogs/moderation/silence.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 1c751a4b1..e1b0b703f 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -91,6 +91,7 @@ class Silence(commands.Cog): If duration is forever, start a notifier loop that triggers every 15 minutes. """ + log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.") if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): await ctx.send(f"{Emojis.cross_mark} current channel is already silenced.") return @@ -100,6 +101,7 @@ class Silence(commands.Cog): await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).") await asyncio.sleep(duration*60) + log.info(f"Unsilencing channel after set delay.") await ctx.invoke(self.unsilence) @commands.command(aliases=("unhush",)) @@ -109,6 +111,7 @@ class Silence(commands.Cog): Unsilence a previously silenced `channel` and remove it from indefinitely muted channels notice if applicable. """ + log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") if await self._unsilence(ctx.channel): await ctx.send(f"{Emojis.check_mark} unsilenced current channel.") -- cgit v1.2.3 From 9bb6f4ae560df90bc45ebe2af449fbb100c5970b Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 21:49:14 +0100 Subject: Improve commands help. --- bot/cogs/moderation/silence.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index e1b0b703f..8ed1cb28b 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -87,9 +87,10 @@ class Silence(commands.Cog): @commands.command(aliases=("hush",)) async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None: """ - Silence `channel` for `duration` minutes or `"forever"`. + Silence `channel` for `duration` minutes or `forever`. - If duration is forever, start a notifier loop that triggers every 15 minutes. + Duration is capped at 15 minutes, passing forever makes the silence indefinite. + Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start. """ log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.") if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): @@ -109,7 +110,8 @@ class Silence(commands.Cog): """ Unsilence `channel`. - Unsilence a previously silenced `channel` and remove it from indefinitely muted channels notice if applicable. + Unsilence a previously silenced `channel`, + remove it from notifier of indefinitely silenced channels and cancel the notifier if empty. """ log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") if await self._unsilence(ctx.channel): -- cgit v1.2.3 From 28cf22bcd98d94fa27e80dde4c86c9054b33c538 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 21:55:09 +0100 Subject: Add tests for `_notifier`. --- tests/bot/cogs/moderation/test_silence.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index c52ca2a2a..d4719159e 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -72,6 +72,25 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): self.notifier.remove_channel(Mock()) self.notifier_stop_mock.assert_not_called() + async def test_notifier_private_sends_alert(self): + """Alert is sent on 15 min intervals.""" + test_cases = (900, 1800, 2700) + for current_loop in test_cases: + with self.subTest(current_loop=current_loop): + with mock.patch.object(self.notifier, "_current_loop", new=current_loop): + await self.notifier._notifier() + self.alert_channel.send.assert_called_once_with(f"<@&{Roles.moderators}> currently silenced channels: ") + self.alert_channel.send.reset_mock() + + async def test_notifier_skips_alert(self): + """Alert is skipped on first loop or not an increment of 900.""" + test_cases = (0, 15, 5000) + for current_loop in test_cases: + with self.subTest(current_loop=current_loop): + with mock.patch.object(self.notifier, "_current_loop", new=current_loop): + await self.notifier._notifier() + self.alert_channel.send.assert_not_called() + class SilenceTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: -- cgit v1.2.3 From 4d333497da0622e0e242b5eee4922932499d2183 Mon Sep 17 00:00:00 2001 From: Jeremiah Boby Date: Wed, 11 Mar 2020 23:36:13 +0000 Subject: Escape markdown in watchlist triggers --- bot/cogs/filtering.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 38c28dd00..6651d38e4 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -6,6 +6,7 @@ import discord.errors from dateutil.relativedelta import relativedelta from discord import Colour, DMChannel, Member, Message, TextChannel from discord.ext.commands import Cog +from discord.utils import escape_markdown from bot.bot import Bot from bot.cogs.moderation import ModLog @@ -195,8 +196,8 @@ class Filtering(Cog): surroundings = match.string[max(match.start() - 10, 0): match.end() + 10] message_content = ( f"**Match:** '{match[0]}'\n" - f"**Location:** '...{surroundings}...'\n" - f"\n**Original Message:**\n{msg.content}" + f"**Location:** '...{escape_markdown(surroundings)}...'\n" + f"\n**Original Message:**\n{escape_markdown(msg.content)}" ) else: # Use content of discord Message message_content = msg.content -- cgit v1.2.3 From 956e76b72efc33b7563a13e43b94ed27d5e263ce Mon Sep 17 00:00:00 2001 From: Jeremiah Boby Date: Wed, 11 Mar 2020 23:41:16 +0000 Subject: Escape markdown in member updates --- bot/cogs/moderation/modlog.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 81d95298d..f9dd10e75 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -12,6 +12,7 @@ from deepdiff import DeepDiff from discord import Colour from discord.abc import GuildChannel from discord.ext.commands import Cog, Context +from discord.utils import escape_markdown from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs @@ -523,7 +524,8 @@ class ModLog(Cog, name="ModLog"): for item in sorted(changes): message += f"{Emojis.bullet} {item}\n" - message = f"**{after}** (`{after.id}`)\n{message}" + member_str = escape_markdown(str(after)) + message = f"**{member_str}** (`{after.id}`)\n{message}" await self.send_log_message( Icons.user_update, Colour.blurple(), -- cgit v1.2.3 From debbc619e47239b268171d7599b363dc8b18c727 Mon Sep 17 00:00:00 2001 From: Jeremiah Boby Date: Wed, 11 Mar 2020 23:57:09 +0000 Subject: Escape markdown in voice updates --- bot/cogs/moderation/modlog.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index f9dd10e75..d42a1ae66 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -387,7 +387,8 @@ class ModLog(Cog, name="ModLog"): if member.guild.id != GuildConstant.id: return - message = f"{member} (`{member.id}`)" + member_str = escape_markdown(str(member)) + message = f"{member_str} (`{member.id}`)" now = datetime.utcnow() difference = abs(relativedelta(now, member.created_at)) @@ -413,9 +414,10 @@ class ModLog(Cog, name="ModLog"): self._ignored[Event.member_remove].remove(member.id) return + member_str = escape_markdown(str(member)) await self.send_log_message( Icons.sign_out, Colours.soft_red, - "User left", f"{member} (`{member.id}`)", + "User left", f"{member_str} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png"), channel_id=Channels.user_log ) @@ -430,9 +432,10 @@ class ModLog(Cog, name="ModLog"): self._ignored[Event.member_unban].remove(member.id) return + member_str = escape_markdown(str(member)) await self.send_log_message( Icons.user_unban, Colour.blurple(), - "User unbanned", f"{member} (`{member.id}`)", + "User unbanned", f"{member_str} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png"), channel_id=Channels.mod_log ) @@ -552,16 +555,17 @@ class ModLog(Cog, name="ModLog"): if author.bot: return + author_str = escape_markdown(str(author)) if channel.category: response = ( - f"**Author:** {author} (`{author.id}`)\n" + f"**Author:** {author_str} (`{author.id}`)\n" f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" ) else: response = ( - f"**Author:** {author} (`{author.id}`)\n" + f"**Author:** {author_str} (`{author.id}`)\n" f"**Channel:** #{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" @@ -648,6 +652,8 @@ class ModLog(Cog, name="ModLog"): return author = msg_before.author + author_str = escape_markdown(str(author)) + channel = msg_before.channel channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}" @@ -679,7 +685,7 @@ class ModLog(Cog, name="ModLog"): content_after.append(sub) response = ( - f"**Author:** {author} (`{author.id}`)\n" + f"**Author:** {author_str} (`{author.id}`)\n" f"**Channel:** {channel_name} (`{channel.id}`)\n" f"**Message ID:** `{msg_before.id}`\n" "\n" @@ -822,8 +828,9 @@ class ModLog(Cog, name="ModLog"): if not changes: return + member_str = escape_markdown(str(member)) message = "\n".join(f"{Emojis.bullet} {item}" for item in sorted(changes)) - message = f"**{member}** (`{member.id}`)\n{message}" + message = f"**{member_str}** (`{member.id}`)\n{message}" await self.send_log_message( icon_url=icon, -- cgit v1.2.3 From 4662cfd29cd9c5ac0081621648a87d102b825852 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 12 Mar 2020 15:05:41 +0100 Subject: Update ytdl tag to the new YouTube ToS --- bot/resources/tags/ytdl.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bot/resources/tags/ytdl.md b/bot/resources/tags/ytdl.md index 09664af26..4c47b0595 100644 --- a/bot/resources/tags/ytdl.md +++ b/bot/resources/tags/ytdl.md @@ -1,9 +1,8 @@ Per [PyDis' Rule 5](https://pythondiscord.com/pages/rules), we are unable to assist with questions related to youtube-dl, commonly used by Discord bots to stream audio, as its use violates YouTube's Terms of Service. -For reference, this usage is covered by the following clauses in [YouTube's TOS](https://www.youtube.com/static?template=terms), as of 2018-05-25: +For reference, this usage is covered by the following clauses in [YouTube's TOS](https://www.youtube.com/static?template=terms), as of 2019-07-22: ``` -4A: You agree not to distribute in any medium any part of the Service or the Content without YouTube's prior written authorization, unless YouTube makes available the means for such distribution through functionality offered by the Service (such as the Embeddable Player). -``` -``` -4C: You agree not to access Content through any technology or means other than the video playback pages of the Service itself, the Embeddable Player, or other explicitly authorized means YouTube may designate. +The following restrictions apply to your use of the Service. You are not allowed to: + +3. access the Service using any automated means (such as robots, botnets or scrapers) except: (a) in the case of public search engines, in accordance with YouTube’s robots.txt file; (b) with YouTube’s prior written permission; or (c) as permitted by applicable law; ``` -- cgit v1.2.3 From f2d10e46e44b4d4cdd3dc343a2462ba00d654409 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Thu, 12 Mar 2020 22:18:24 +0530 Subject: remove repetitive file search --- bot/cogs/tags.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 9665aa04e..692cff0d8 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -36,12 +36,11 @@ class Tags(Cog): cache = {} tag_files = Path("bot", "resources", "tags").iterdir() for file in tag_files: - file_path = Path(file) - tag_title = file_path.stem + tag_title = file.stem tag = { "title": tag_title, "embed": { - "description": file_path.read_text() + "description": file.read_text() } } cache[tag_title] = tag -- cgit v1.2.3 From 55effb33627fe21a4bcd230a26cc3cf2dfb0b512 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Thu, 12 Mar 2020 22:32:24 +0530 Subject: convert get_tags() method to staticmethod --- bot/cogs/tags.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 692cff0d8..48f000143 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -30,7 +30,8 @@ class Tags(Cog): self.tag_cooldowns = {} self._cache = self.get_tags() - def get_tags(self) -> dict: + @staticmethod + def get_tags() -> dict: """Get all tags.""" # Save all tags in memory. cache = {} -- cgit v1.2.3 From 96639bca024f5b22b7e44b59de0d75aebe9c7f20 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Thu, 12 Mar 2020 13:30:41 -0500 Subject: Corrected expiration check logic and cog loading Bugs fixed: - Previously, the code would check to see if `'expires_at'` was in the kwargs, which after testing I came to find out that it is regardless of the duration of the ban. It has sense been changed to use a `.get()` in order to do a proper comparison. - Code previously attempted to load from the `"BigBrother"` cog which is the incorrect spelling. Changed it to `"Big Brother"` to correct this. Logging Added: - Additional trace logs added to both the `infractions.py` file as well as `bigbrother.py` to assist with future debugging or testing. Signed-off-by: Daniel Brown --- bot/cogs/moderation/infractions.py | 7 +++++-- bot/cogs/watchchannels/bigbrother.py | 4 ++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 9bab38e23..3ea185d29 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -244,15 +244,18 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user, action) # Remove perma banned users from the watch list - if 'expires_at' not in kwargs: - bb_cog = self.bot.get_cog("BigBrother") + if infraction.get('expires_at') is None: + log.trace("Ban was a permanent one. Attempt to remove from watched list.") + bb_cog = self.bot.get_cog("Big Brother") if bb_cog: + log.trace("Cog loaded. Attempting to remove from list.") await bb_cog.apply_unwatch( ctx, user, "User has been permanently banned from the server. Automatically removed.", banned=True ) + log.debug("Perma banned user removed from watch list.") # endregion # region: Base pardon functions diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 75b66839e..caae793bb 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -110,6 +110,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): ) ) if active_watches: + log.trace("Active watches for user found. Attempting to remove.") [infraction] = active_watches await self.bot.api_client.patch( @@ -120,9 +121,12 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await post_infraction(ctx, user, 'watch', f"Unwatched: {reason}", hidden=True, active=False) if not banned: # Prevents a message being sent to the channel if part of a permanent ban + log.trace("User is not banned. Sending message to channel") await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed.") self._remove_user(user.id) else: + log.trace("No active watches found for user.") if not banned: # Prevents a message being sent to the channel if part of a permanent ban + log.trace("User is not perma banned. Send the error message.") await ctx.send(":x: The specified user is currently not being watched.") -- cgit v1.2.3 From 9b18912d2d4a6c575e7f45a55f34d6dab41f6b57 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Fri, 13 Mar 2020 14:52:15 -0500 Subject: Verification Cog Kaizen Changes Kaizen: - Cut down on the size of the import line by changing the imports from bot.constants to instead just importing the constants. This will help clarify where certain constants are coming from. - The periodic checkpoint message will no longer ping `@everyone` or `@Admins` when the bot detects that it is being ran in a debug environment. Message is now a simple confirmation that the periodic ping method successfully ran. Signed-off-by: Daniel Brown --- bot/cogs/verification.py | 71 ++++++++++++++++++++++++------------------------ 1 file changed, 36 insertions(+), 35 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 57b50c34f..107bc1058 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -6,13 +6,9 @@ from discord import Colour, Forbidden, Message, NotFound, Object from discord.ext import tasks from discord.ext.commands import Cog, Context, command +from bot import constants from bot.bot import Bot from bot.cogs.moderation import ModLog -from bot.constants import ( - Bot as BotConfig, - Channels, Colours, Event, - Filter, Icons, MODERATION_ROLES, Roles -) from bot.decorators import InChannelCheckFailure, in_channel, without_role from bot.utils.checks import without_role_check @@ -29,18 +25,23 @@ your information removed here as well. Feel free to review them at any point! -Additionally, if you'd like to receive notifications for the announcements we post in <#{Channels.announcements}> \ -from time to time, you can send `!subscribe` to <#{Channels.bot_commands}> at any time to assign yourself the \ -**Announcements** role. We'll mention this role every time we make an announcement. +Additionally, if you'd like to receive notifications for the announcements \ +we post in <#{constants.Channels.announcements}> +from time to time, you can send `!subscribe` to <#{constants.Channels.bot_commands}> at any time \ +to assign yourself the **Announcements** role. We'll mention this role every time we make an announcement. If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to \ -<#{Channels.bot_commands}>. +<#{constants.Channels.bot_commands}>. """ -PERIODIC_PING = ( - f"@everyone To verify that you have read our rules, please type `{BotConfig.prefix}accept`." - f" If you encounter any problems during the verification process, ping the <@&{Roles.admins}> role in this channel." -) +if constants.DEBUG_MODE: + PERIODIC_PING = "Periodic checkpoint message successfully sent." +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." + ) BOT_MESSAGE_DELETE_DELAY = 10 @@ -59,7 +60,7 @@ class Verification(Cog): @Cog.listener() async def on_message(self, message: Message) -> None: """Check new message event for messages to the checkpoint channel & process.""" - if message.channel.id != Channels.verification: + if message.channel.id != constants.Channels.verification: return # Only listen for #checkpoint messages if message.author.bot: @@ -85,20 +86,20 @@ class Verification(Cog): # Send pretty mod log embed to mod-alerts await self.mod_log.send_log_message( - icon_url=Icons.filtering, - colour=Colour(Colours.soft_red), + icon_url=constants.Icons.filtering, + colour=Colour(constants.Colours.soft_red), title=f"User/Role mentioned in {message.channel.name}", text=embed_text, thumbnail=message.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts, - ping_everyone=Filter.ping_everyone, + channel_id=constants.Channels.mod_alerts, + ping_everyone=constants.Filter.ping_everyone, ) - ctx: Context = await self.bot.get_context(message) + ctx: Context = await self.get_context(message) if ctx.command is not None and ctx.command.name == "accept": return - if any(r.id == Roles.verified for r in ctx.author.roles): + if any(r.id == constants.Roles.verified for r in ctx.author.roles): log.info( f"{ctx.author} posted '{ctx.message.content}' " "in the verification channel, but is already verified." @@ -120,12 +121,12 @@ class Verification(Cog): await ctx.message.delete() @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True) - @without_role(Roles.verified) - @in_channel(Channels.verification) + @without_role(constants.Roles.verified) + @in_channel(constants.Channels.verification) async def accept_command(self, ctx: Context, *_) -> None: # We don't actually care about the args """Accept our rules and gain access to the rest of the server.""" log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.") - await ctx.author.add_roles(Object(Roles.verified), reason="Accepted the rules") + await ctx.author.add_roles(Object(constants.Roles.verified), reason="Accepted the rules") try: await ctx.author.send(WELCOME_MESSAGE) except Forbidden: @@ -133,17 +134,17 @@ class Verification(Cog): finally: log.trace(f"Deleting accept message by {ctx.author}.") with suppress(NotFound): - self.mod_log.ignore(Event.message_delete, ctx.message.id) + self.mod_log.ignore(constants.Event.message_delete, ctx.message.id) await ctx.message.delete() @command(name='subscribe') - @in_channel(Channels.bot_commands) + @in_channel(constants.Channels.bot_commands) async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args """Subscribe to announcement notifications by assigning yourself the role.""" has_role = False for role in ctx.author.roles: - if role.id == Roles.announcements: + if role.id == constants.Roles.announcements: has_role = True break @@ -152,22 +153,22 @@ class Verification(Cog): return log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.") - await ctx.author.add_roles(Object(Roles.announcements), reason="Subscribed to announcements") + await ctx.author.add_roles(Object(constants.Roles.announcements), reason="Subscribed to announcements") log.trace(f"Deleting the message posted by {ctx.author}.") await ctx.send( - f"{ctx.author.mention} Subscribed to <#{Channels.announcements}> notifications.", + f"{ctx.author.mention} Subscribed to <#{constants.Channels.announcements}> notifications.", ) @command(name='unsubscribe') - @in_channel(Channels.bot_commands) + @in_channel(constants.Channels.bot_commands) async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args """Unsubscribe from announcement notifications by removing the role from yourself.""" has_role = False for role in ctx.author.roles: - if role.id == Roles.announcements: + if role.id == constants.Roles.announcements: has_role = True break @@ -176,12 +177,12 @@ class Verification(Cog): return log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.") - await ctx.author.remove_roles(Object(Roles.announcements), reason="Unsubscribed from announcements") + await ctx.author.remove_roles(Object(constants.Roles.announcements), reason="Unsubscribed from announcements") log.trace(f"Deleting the message posted by {ctx.author}.") await ctx.send( - f"{ctx.author.mention} Unsubscribed from <#{Channels.announcements}> notifications." + f"{ctx.author.mention} Unsubscribed from <#{constants.Channels.announcements}> notifications." ) # This cannot be static (must have a __func__ attribute). @@ -193,7 +194,7 @@ class Verification(Cog): @staticmethod def bot_check(ctx: Context) -> bool: """Block any command within the verification channel that is not !accept.""" - if ctx.channel.id == Channels.verification and without_role_check(ctx, *MODERATION_ROLES): + if ctx.channel.id == constants.Channels.verification and without_role_check(ctx, *constants.MODERATION_ROLES): return ctx.command.name == "accept" else: return True @@ -201,7 +202,7 @@ class Verification(Cog): @tasks.loop(hours=12) async def periodic_ping(self) -> None: """Every week, mention @everyone to remind them to verify.""" - messages = self.bot.get_channel(Channels.verification).history(limit=10) + messages = self.bot.get_channel(constants.Channels.verification).history(limit=10) need_to_post = True # True if a new message needs to be sent. async for message in messages: @@ -215,7 +216,7 @@ class Verification(Cog): break if need_to_post: - await self.bot.get_channel(Channels.verification).send(PERIODIC_PING) + await self.bot.get_channel(constants.Channels.verification).send(PERIODIC_PING) @periodic_ping.before_loop async def before_ping(self) -> None: -- cgit v1.2.3 From d9ed24922f6daa17d625b345cb195e7fae7758cc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 13:31:44 -0700 Subject: Cog tests: add a function to get all extensions --- tests/bot/cogs/test_cogs.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index b128ca123..386299fb1 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -1,10 +1,15 @@ """Test suite for general tests which apply to all cogs.""" +import importlib +import pkgutil import typing as t import unittest +from types import ModuleType from discord.ext import commands +from bot import cogs + class CommandNameTests(unittest.TestCase): """Tests for shadowing command names and aliases.""" @@ -17,3 +22,9 @@ class CommandNameTests(unittest.TestCase): yield command if isinstance(command, commands.GroupMixin): yield from command.walk_commands() + + @staticmethod + def walk_extensions() -> t.Iterator[ModuleType]: + """Yield imported extensions (modules) from the bot.cogs subpackage.""" + for module in pkgutil.iter_modules(cogs.__path__, "bot.cogs."): + yield importlib.import_module(module.name) -- cgit v1.2.3 From b923c0f844f65275d90e3807aa8e3eadf3920252 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 13:37:56 -0700 Subject: Cog tests: add a function to get all cogs --- tests/bot/cogs/test_cogs.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index 386299fb1..4290c279c 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -28,3 +28,10 @@ class CommandNameTests(unittest.TestCase): """Yield imported extensions (modules) from the bot.cogs subpackage.""" for module in pkgutil.iter_modules(cogs.__path__, "bot.cogs."): yield importlib.import_module(module.name) + + @staticmethod + def walk_cogs(extension: ModuleType) -> t.Iterator[commands.Cog]: + """Yield all cogs defined in an extension.""" + for name, cls in extension.__dict__.items(): + if isinstance(cls, commands.Cog): + yield getattr(extension, name) -- cgit v1.2.3 From 0358121687988159cb6754e249eed1ee2d40a783 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 13:56:58 -0700 Subject: Cog tests: add a function to get all qualified names for a cmd --- tests/bot/cogs/test_cogs.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index 4290c279c..e28717756 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -35,3 +35,11 @@ class CommandNameTests(unittest.TestCase): for name, cls in extension.__dict__.items(): if isinstance(cls, commands.Cog): yield getattr(extension, name) + + @staticmethod + def get_qualified_names(command: commands.Command) -> t.List[str]: + """Return a list of all qualified names, including aliases, for the `command`.""" + names = [f"{command.full_parent_name} {alias}" for alias in command.aliases] + names.append(command.qualified_name) + + return names -- cgit v1.2.3 From 5419b3e9599e8bb2f519949aa268eb3a4b3adbcc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 14:17:23 -0700 Subject: Cog tests: add a function to yield all commands This will help reduce nesting in the actual test. --- tests/bot/cogs/test_cogs.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index e28717756..d260b46a7 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -43,3 +43,10 @@ class CommandNameTests(unittest.TestCase): names.append(command.qualified_name) return names + + def get_all_commands(self) -> t.Iterator[commands.Command]: + """Yield all commands for all cogs in all extensions.""" + for extension in self.walk_extensions(): + for cog in self.walk_cogs(extension): + for cmd in self.walk_commands(cog): + yield cmd -- cgit v1.2.3 From 1b4def2c8c0d82fc9738c1e969404e305c91cac9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 14:31:56 -0700 Subject: Cog tests: fix Cog type check in `walk_cogs` --- tests/bot/cogs/test_cogs.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index d260b46a7..75aa1dbf6 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -32,9 +32,9 @@ class CommandNameTests(unittest.TestCase): @staticmethod def walk_cogs(extension: ModuleType) -> t.Iterator[commands.Cog]: """Yield all cogs defined in an extension.""" - for name, cls in extension.__dict__.items(): - if isinstance(cls, commands.Cog): - yield getattr(extension, name) + for obj in extension.__dict__.values(): + if isinstance(obj, type) and issubclass(obj, commands.Cog): + yield obj @staticmethod def get_qualified_names(command: commands.Command) -> t.List[str]: -- cgit v1.2.3 From 78327b9fa7c64a04d527fce582b93210356451fe Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 14:49:08 -0700 Subject: Cog tests: fix duplicate cogs being yielded Have to check the modules are equal to prevent yielding imported cogs. --- tests/bot/cogs/test_cogs.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index 75aa1dbf6..de0982c93 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -33,7 +33,8 @@ class CommandNameTests(unittest.TestCase): def walk_cogs(extension: ModuleType) -> t.Iterator[commands.Cog]: """Yield all cogs defined in an extension.""" for obj in extension.__dict__.values(): - if isinstance(obj, type) and issubclass(obj, commands.Cog): + is_cog = isinstance(obj, type) and issubclass(obj, commands.Cog) + if is_cog and obj.__module__ == extension.__name__: yield obj @staticmethod -- cgit v1.2.3 From bbcdf24a4b5d4f84834bbc8a8da7db2da627541f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 15:02:22 -0700 Subject: Cog tests: fix nested modules not being found * Rename `walk_extensions` to `walk_modules` because some extensions don't consist of a single module --- tests/bot/cogs/test_cogs.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index de0982c93..3a9f07db6 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -24,17 +24,21 @@ class CommandNameTests(unittest.TestCase): yield from command.walk_commands() @staticmethod - def walk_extensions() -> t.Iterator[ModuleType]: - """Yield imported extensions (modules) from the bot.cogs subpackage.""" - for module in pkgutil.iter_modules(cogs.__path__, "bot.cogs."): - yield importlib.import_module(module.name) + def walk_modules() -> t.Iterator[ModuleType]: + """Yield imported modules from the bot.cogs subpackage.""" + def on_error(name: str) -> t.NoReturn: + raise ImportError(name=name) + + for module in pkgutil.walk_packages(cogs.__path__, "bot.cogs.", onerror=on_error): + if not module.ispkg: + yield importlib.import_module(module.name) @staticmethod - def walk_cogs(extension: ModuleType) -> t.Iterator[commands.Cog]: + def walk_cogs(module: ModuleType) -> t.Iterator[commands.Cog]: """Yield all cogs defined in an extension.""" - for obj in extension.__dict__.values(): + for obj in module.__dict__.values(): is_cog = isinstance(obj, type) and issubclass(obj, commands.Cog) - if is_cog and obj.__module__ == extension.__name__: + if is_cog and obj.__module__ == module.__name__: yield obj @staticmethod @@ -47,7 +51,7 @@ class CommandNameTests(unittest.TestCase): def get_all_commands(self) -> t.Iterator[commands.Command]: """Yield all commands for all cogs in all extensions.""" - for extension in self.walk_extensions(): - for cog in self.walk_cogs(extension): + for module in self.walk_modules(): + for cog in self.walk_cogs(module): for cmd in self.walk_commands(cog): yield cmd -- cgit v1.2.3 From 02fe32879be51b3f202501ea8cdc5314ca3b77b2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 15:29:19 -0700 Subject: Cog tests: fix duplicate commands being yielded discord.py yields duplicate Command objects for each alias a command has, so the duplicates need to be removed on our end. --- tests/bot/cogs/test_cogs.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index 3a9f07db6..9d1d4ebea 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -21,7 +21,8 @@ class CommandNameTests(unittest.TestCase): if command.parent is None: yield command if isinstance(command, commands.GroupMixin): - yield from command.walk_commands() + # Annoyingly it returns duplicates for each alias so use a set to fix that + yield from set(command.walk_commands()) @staticmethod def walk_modules() -> t.Iterator[ModuleType]: -- cgit v1.2.3 From 7e7c538435c899f45ff277e05fb59d139f401954 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 15:34:12 -0700 Subject: Cog tests: add a test for duplicate command names & aliases --- tests/bot/cogs/test_cogs.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index 9d1d4ebea..616f5f44a 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -4,6 +4,7 @@ import importlib import pkgutil import typing as t import unittest +from collections import defaultdict from types import ModuleType from discord.ext import commands @@ -56,3 +57,19 @@ class CommandNameTests(unittest.TestCase): for cog in self.walk_cogs(module): for cmd in self.walk_commands(cog): yield cmd + + def test_names_dont_shadow(self): + """Names and aliases of commands should be unique.""" + all_names = defaultdict(list) + for cmd in self.get_all_commands(): + func_name = f"{cmd.module}.{cmd.callback.__qualname__}" + + for name in self.get_qualified_names(cmd): + with self.subTest(cmd=func_name, name=name): + if name in all_names: + conflicts = ", ".join(all_names.get(name, "")) + self.fail( + f"Name '{name}' of the command {func_name} conflicts with {conflicts}." + ) + + all_names[name].append(func_name) -- cgit v1.2.3 From f105ae75a98ae3e0295352d7debbc4fe04c73afd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 13 Mar 2020 17:32:16 -0700 Subject: Cog tests: fix leading space in aliases without parents --- tests/bot/cogs/test_cogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index 616f5f44a..cbd203786 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -46,7 +46,7 @@ class CommandNameTests(unittest.TestCase): @staticmethod def get_qualified_names(command: commands.Command) -> t.List[str]: """Return a list of all qualified names, including aliases, for the `command`.""" - names = [f"{command.full_parent_name} {alias}" for alias in command.aliases] + names = [f"{command.full_parent_name} {alias}".strip() for alias in command.aliases] names.append(command.qualified_name) return names -- cgit v1.2.3 From 4d4975544ffec249aa6cd43d14987c00794caf99 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 13 Mar 2020 17:42:24 -0700 Subject: Cog tests: fix error on import due to discord.ext.tasks.loop The tasks extensions loop requires an event loop to exist. To work around this, it's been mocked. --- tests/bot/cogs/test_cogs.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index cbd203786..db559ded6 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -6,6 +6,7 @@ import typing as t import unittest from collections import defaultdict from types import ModuleType +from unittest import mock from discord.ext import commands @@ -31,9 +32,10 @@ class CommandNameTests(unittest.TestCase): def on_error(name: str) -> t.NoReturn: raise ImportError(name=name) - for module in pkgutil.walk_packages(cogs.__path__, "bot.cogs.", onerror=on_error): - if not module.ispkg: - yield importlib.import_module(module.name) + with mock.patch("discord.ext.tasks.loop"): + for module in pkgutil.walk_packages(cogs.__path__, "bot.cogs.", onerror=on_error): + if not module.ispkg: + yield importlib.import_module(module.name) @staticmethod def walk_cogs(module: ModuleType) -> t.Iterator[commands.Cog]: -- cgit v1.2.3 From e9ae8a89ec97c3bdb53dd354e292c53ad72fb42e Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Sat, 14 Mar 2020 19:43:31 +0530 Subject: Remove line that calls get_tags() method The tags have now been shifted from the database to being static files and hence the get_tags() method has undergone changes. It now dosen't fetch from the database but looks at the local files and we need not call it more than once. --- bot/cogs/tags.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index fecaf926d..fee24b2e7 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -97,8 +97,6 @@ class Tags(Cog): `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() -- cgit v1.2.3 From 52ed9aa590a4c190f70778448ec926df4f2d0119 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 14 Mar 2020 12:35:24 -0700 Subject: Tags: use constant for command prefix in embed footer * Add a constant for the footer text * Import constants module rather than its classes --- bot/cogs/tags.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index fee24b2e7..09ce5a413 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -7,19 +7,20 @@ from typing import Callable, Dict, Iterable, List, Optional from discord import Colour, Embed from discord.ext.commands import Cog, Context, group +from bot import constants from bot.bot import Bot -from bot.constants import Channels, Cooldowns from bot.converters import TagNameConverter from bot.pagination import LinePaginator log = logging.getLogger(__name__) TEST_CHANNELS = ( - Channels.bot_commands, - Channels.helpers + constants.Channels.bot_commands, + constants.Channels.helpers ) REGEX_NON_ALPHABET = re.compile(r"[^a-z]", re.MULTILINE & re.IGNORECASE) +FOOTER_TEXT = f"To show a tag, type {constants.Bot.prefix}tags ." class Tags(Cog): @@ -133,7 +134,7 @@ class Tags(Cog): sorted(f"**»** {tag['title']}" for tag in matching_tags), ctx, embed, - footer_text="To show a tag, type !tags .", + footer_text=FOOTER_TEXT, empty=False, max_lines=15 ) @@ -177,7 +178,7 @@ class Tags(Cog): cooldown_conditions = ( tag_name and tag_name in self.tag_cooldowns - and (now - self.tag_cooldowns[tag_name]["time"]) < Cooldowns.tags + and (now - self.tag_cooldowns[tag_name]["time"]) < constants.Cooldowns.tags and self.tag_cooldowns[tag_name]["channel"] == ctx.channel.id ) @@ -186,7 +187,8 @@ class Tags(Cog): return False if _command_on_cooldown(tag_name): - time_left = Cooldowns.tags - (time.time() - self.tag_cooldowns[tag_name]["time"]) + time_elapsed = time.time() - self.tag_cooldowns[tag_name]["time"] + time_left = constants.Cooldowns.tags - time_elapsed log.info( f"{ctx.author} tried to get the '{tag_name}' tag, but the tag is on cooldown. " f"Cooldown ends in {time_left:.1f} seconds." @@ -223,7 +225,7 @@ class Tags(Cog): sorted(f"**»** {tag['title']}" for tag in tags), ctx, embed, - footer_text="To show a tag, type !tags .", + footer_text=FOOTER_TEXT, empty=False, max_lines=15 ) -- cgit v1.2.3 From eeacceae01b95e39eaeecab2fd14f6edfb19b94b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 14 Mar 2020 12:43:49 -0700 Subject: Tags: add restrictions 1 & 9 from YouTube ToS to ytdl tag --- bot/resources/tags/ytdl.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/resources/tags/ytdl.md b/bot/resources/tags/ytdl.md index 4c47b0595..e34ecff44 100644 --- a/bot/resources/tags/ytdl.md +++ b/bot/resources/tags/ytdl.md @@ -2,7 +2,11 @@ Per [PyDis' Rule 5](https://pythondiscord.com/pages/rules), we are unable to ass For reference, this usage is covered by the following clauses in [YouTube's TOS](https://www.youtube.com/static?template=terms), as of 2019-07-22: ``` -The following restrictions apply to your use of the Service. You are not allowed to: +The following restrictions apply to your use of the Service. You are not allowed to: -3. access the Service using any automated means (such as robots, botnets or scrapers) except: (a) in the case of public search engines, in accordance with YouTube’s robots.txt file; (b) with YouTube’s prior written permission; or (c) as permitted by applicable law; +1. access, reproduce, download, distribute, transmit, broadcast, display, sell, license, alter, modify or otherwise use any part of the Service or any Content except: (a) as specifically permitted by the Service; (b) with prior written permission from YouTube and, if applicable, the respective rights holders; or (c) as permitted by applicable law; + +3. access the Service using any automated means (such as robots, botnets or scrapers) except: (a) in the case of public search engines, in accordance with YouTube’s robots.txt file; (b) with YouTube’s prior written permission; or (c) as permitted by applicable law; + +9. use the Service to view or listen to Content other than for personal, non-commercial use (for example, you may not publicly screen videos or stream music from the Service) ``` -- cgit v1.2.3 From a43315e3c4ec2725aec307de765898a85be02cc5 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Sat, 14 Mar 2020 16:42:24 -0500 Subject: Update bot/cogs/moderation/infractions.py Co-Authored-By: Mark --- bot/cogs/moderation/infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 3ea185d29..f68f8ba9a 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -67,7 +67,7 @@ class Infractions(InfractionScheduler, commands.Cog): @command() async def ban(self, ctx: Context, user: FetchedMember, *, reason: str = None) -> None: - """Permanently ban a user for the given reason. Also removes them from the BigBrother watch list.""" + """Permanently ban a user for the given reason and stop watching them with Big Brother.""" await self.apply_ban(ctx, user, reason) # endregion -- cgit v1.2.3 From 5865126b87aad1a9fd425f9b131d8520c82a96a5 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Sun, 15 Mar 2020 17:05:55 +0530 Subject: convert _get_tags_via_content() method to non-async --- bot/cogs/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index fee24b2e7..ff3be7f4a 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -91,7 +91,7 @@ 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: + def _get_tags_via_content(self, check: Callable[[Iterable], bool], keywords: str) -> list: """ Search for tags via contents. -- cgit v1.2.3 From 520c73093af8337c34fb7147ba7d7d15bf46a2af Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Sun, 15 Mar 2020 18:37:37 +0530 Subject: not awaiting _get_tags_via_content() method as it is non-async --- bot/cogs/tags.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 4895bd807..5b820978d 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -151,7 +151,7 @@ class Tags(Cog): Only search for tags that has ALL the keywords. """ - matching_tags = await self._get_tags_via_content(all, keywords) + matching_tags = self._get_tags_via_content(all, keywords) await self._send_matching_tags(ctx, keywords, matching_tags) @search_tag_content.command(name='any') @@ -161,7 +161,7 @@ class Tags(Cog): Search for tags that has ANY of the keywords. """ - matching_tags = await self._get_tags_via_content(any, keywords or 'any') + matching_tags = 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')) -- cgit v1.2.3 From 252b385e46ef542203e69f4f6d147dadbcec8f0f Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Mar 2020 16:11:35 +0100 Subject: Use dict instead of a set and custom class. The FirstHash class is no longer necessary with only channels and the current loop in tuples. FirstHash was removed, along with its tests and tests were adjusted for new dict behaviour. --- bot/cogs/moderation/silence.py | 24 +++++------------------- tests/bot/cogs/moderation/test_silence.py | 28 +++------------------------- 2 files changed, 8 insertions(+), 44 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 8ed1cb28b..5df1fbbc0 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -15,26 +15,12 @@ from bot.utils.checks import with_role_check log = logging.getLogger(__name__) -class FirstHash(tuple): - """Tuple with only first item used for hash and eq.""" - - def __new__(cls, *args): - """Construct tuple from `args`.""" - return super().__new__(cls, args) - - def __hash__(self): - return hash((self[0],)) - - def __eq__(self, other: "FirstHash"): - return self[0] == other[0] - - class SilenceNotifier(tasks.Loop): """Loop notifier for posting notices to `alert_channel` containing added channels.""" def __init__(self, alert_channel: TextChannel): super().__init__(self._notifier, seconds=1, minutes=0, hours=0, count=None, reconnect=True, loop=None) - self._silenced_channels = set() + self._silenced_channels = {} self._alert_channel = alert_channel def add_channel(self, channel: TextChannel) -> None: @@ -42,12 +28,12 @@ class SilenceNotifier(tasks.Loop): if not self._silenced_channels: self.start() log.info("Starting notifier loop.") - self._silenced_channels.add(FirstHash(channel, self._current_loop)) + self._silenced_channels[channel] = self._current_loop def remove_channel(self, channel: TextChannel) -> None: """Remove channel from `_silenced_channels` and stop loop if no channels remain.""" with suppress(KeyError): - self._silenced_channels.remove(FirstHash(channel)) + del self._silenced_channels[channel] if not self._silenced_channels: self.stop() log.info("Stopping notifier loop.") @@ -58,11 +44,11 @@ class SilenceNotifier(tasks.Loop): if self._current_loop and not self._current_loop/60 % 15: log.debug( f"Sending notice with channels: " - f"{', '.join(f'#{channel} ({channel.id})' for channel, _ in self._silenced_channels)}." + f"{', '.join(f'#{channel} ({channel.id})' for channel in self._silenced_channels)}." ) channels_text = ', '.join( f"{channel.mention} for {(self._current_loop-start)//60} min" - for channel, start in self._silenced_channels + for channel, start in self._silenced_channels.items() ) await self._alert_channel.send(f"<@&{Roles.moderators}> currently silenced channels: {channels_text}") diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index d4719159e..6114fee21 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -2,33 +2,11 @@ import unittest from unittest import mock from unittest.mock import MagicMock, Mock -from bot.cogs.moderation.silence import FirstHash, Silence, SilenceNotifier +from bot.cogs.moderation.silence import Silence, SilenceNotifier from bot.constants import Channels, Emojis, Guild, Roles from tests.helpers import MockBot, MockContext, MockTextChannel -class FirstHashTests(unittest.TestCase): - def setUp(self) -> None: - self.test_cases = ( - (FirstHash(0, 4), FirstHash(0, 5)), - (FirstHash("string", None), FirstHash("string", True)) - ) - - def test_hashes_equal(self): - """Check hashes equal with same first item.""" - - for tuple1, tuple2 in self.test_cases: - with self.subTest(tuple1=tuple1, tuple2=tuple2): - self.assertEqual(hash(tuple1), hash(tuple2)) - - def test_eq(self): - """Check objects are equal with same first item.""" - - for tuple1, tuple2 in self.test_cases: - with self.subTest(tuple1=tuple1, tuple2=tuple2): - self.assertTrue(tuple1 == tuple2) - - class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.alert_channel = MockTextChannel() @@ -41,7 +19,7 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): channel = Mock() with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: self.notifier.add_channel(channel) - silenced_channels.add.assert_called_with(FirstHash(channel, self.notifier._current_loop)) + silenced_channels.__setitem__.assert_called_with(channel, self.notifier._current_loop) def test_add_channel_starts_loop(self): """Loop is started if `_silenced_channels` was empty.""" @@ -59,7 +37,7 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): channel = Mock() with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: self.notifier.remove_channel(channel) - silenced_channels.remove.assert_called_with(FirstHash(channel)) + silenced_channels.__delitem__.assert_called_with(channel) def test_remove_channel_stops_loop(self): """Notifier loop is stopped if `_silenced_channels` is empty after remove.""" -- cgit v1.2.3 From 39e9fc2dced2a4ce5e210f671bb03036ee91c2c6 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Mar 2020 16:27:53 +0100 Subject: Adjust docstring styling. Co-authored-by: MarkKoz --- bot/cogs/error_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 45ab1f326..7989acde7 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -32,8 +32,8 @@ class ErrorHandler(Cog): prioritised as follows: 1. If the name fails to match a command: - If it matches shh+ or unshh+, the channel is silenced or unsilenced respectively. - otherwise if it matches a tag, the tag is invoked + * If it matches shh+ or unshh+, the channel is silenced or unsilenced respectively. + Otherwise if it matches a tag, the tag is invoked * If CommandNotFound is raised when invoking the tag (determined by the presence of the `invoked_from_error_handler` attribute), this error is treated as being unexpected and therefore sends an error message -- cgit v1.2.3 From dc534b72fcb561c057b1584311ca9e27244f08ae Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Mar 2020 16:47:03 +0100 Subject: Move coro execution outside of if condition. This gives us a clearer look at the general flow control and what's getting executed. Comment was also moved to its relevant line. Co-authored-by: MarkKoz --- bot/cogs/error_handler.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 7989acde7..73757b7b7 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -50,15 +50,13 @@ class ErrorHandler(Cog): log.trace(f"Command {command} had its error already handled locally; ignoring.") return - # Try to look for a tag with the command's name if the command isn't found. - if isinstance(e, errors.CommandNotFound): - if ( - not await self.try_silence(ctx) - and not hasattr(ctx, "invoked_from_error_handler") - and ctx.channel.id != Channels.verification - ): + if isinstance(e, errors.CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): + if await self.try_silence(ctx): + return + if ctx.channel.id != Channels.verification: + # Try to look for a tag with the command's name await self.try_get_tag(ctx) - return # Exit early to avoid logging. + return # Exit early to avoid logging. elif isinstance(e, errors.UserInputError): await self.handle_user_input_error(ctx, e) elif isinstance(e, errors.CheckFailure): -- cgit v1.2.3 From d2fad8a0e21a1cb074244bc371c88cf488229774 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Mar 2020 16:47:45 +0100 Subject: Add Silence cog load to docstring. --- bot/cogs/moderation/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py index 0349fe4b1..6880ca1bd 100644 --- a/bot/cogs/moderation/__init__.py +++ b/bot/cogs/moderation/__init__.py @@ -7,7 +7,7 @@ from .superstarify import Superstarify def setup(bot: Bot) -> None: - """Load the Infractions, ModManagement, ModLog, and Superstarify cogs.""" + """Load the Infractions, ModManagement, ModLog, Silence, and Superstarify cogs.""" bot.add_cog(Infractions(bot)) bot.add_cog(ModLog(bot)) bot.add_cog(ModManagement(bot)) -- cgit v1.2.3 From 4c2b8b715abd32b6818ef0cf4cdd96369eec192e Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Mar 2020 16:49:37 +0100 Subject: Change BadArgument error wording. Co-authored-by: MarkKoz --- bot/converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/converters.py b/bot/converters.py index 976376fce..635fef1c7 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -286,7 +286,7 @@ class HushDurationConverter(Converter): duration = int(match.group(1)) if duration > 15: - raise BadArgument("Duration must be below 15 minutes.") + raise BadArgument("Duration must be at most 15 minutes.") return duration -- cgit v1.2.3 From 678f8552fddcaca65289f6da0bcaa0c7b5ac14d1 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Mar 2020 17:56:24 +0100 Subject: Pass kwargs directly instead of a PermissionOverwrite. The `set_permissions` method creates a `PermissionOverwrite` from kwargs internally, so we can skip creating it ourselves and unpack the dict directly into kwargs. --- bot/cogs/moderation/silence.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 5df1fbbc0..3d6ca8867 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -3,7 +3,7 @@ import logging from contextlib import suppress from typing import Optional -from discord import PermissionOverwrite, TextChannel +from discord import TextChannel from discord.ext import commands, tasks from discord.ext.commands import Context @@ -114,10 +114,7 @@ class Silence(commands.Cog): if current_overwrite.send_messages is False: log.info(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.") return False - await channel.set_permissions( - self._verified_role, - overwrite=PermissionOverwrite(**dict(current_overwrite, send_messages=False)) - ) + await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=False)) self.muted_channels.add(channel) if persistent: log.info(f"Silenced #{channel} ({channel.id}) indefinitely.") @@ -136,10 +133,7 @@ class Silence(commands.Cog): """ current_overwrite = channel.overwrites_for(self._verified_role) if current_overwrite.send_messages is False: - await channel.set_permissions( - self._verified_role, - overwrite=PermissionOverwrite(**dict(current_overwrite, send_messages=True)) - ) + await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=True)) log.info(f"Unsilenced channel #{channel} ({channel.id}).") self.notifier.remove_channel(channel) with suppress(KeyError): -- cgit v1.2.3 From a52bff17234788f40e8a349cf78e72579621e8db Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Mar 2020 18:02:54 +0100 Subject: Assign created task to a var. --- bot/cogs/moderation/silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 3d6ca8867..3a3acf216 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -59,7 +59,7 @@ class Silence(commands.Cog): def __init__(self, bot: Bot): self.bot = bot self.muted_channels = set() - self.bot.loop.create_task(self._get_instance_vars()) + self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars()) async def _get_instance_vars(self) -> None: """Get instance variables after they're available to get from the guild.""" -- cgit v1.2.3 From a17ccdb3d22d10070dfdc79077555fa840f93e96 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Mar 2020 18:06:20 +0100 Subject: Block commands until all instance vars are loaded. --- bot/cogs/moderation/silence.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 3a3acf216..42047d0f7 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -60,6 +60,7 @@ class Silence(commands.Cog): self.bot = bot self.muted_channels = set() self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars()) + self._get_instance_vars_event = asyncio.Event() async def _get_instance_vars(self) -> None: """Get instance variables after they're available to get from the guild.""" @@ -69,6 +70,7 @@ class Silence(commands.Cog): self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts) self._mod_log_channel = self.bot.get_channel(Channels.mod_log) self.notifier = SilenceNotifier(self._mod_log_channel) + self._get_instance_vars_event.set() @commands.command(aliases=("hush",)) async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None: @@ -78,6 +80,7 @@ class Silence(commands.Cog): Duration is capped at 15 minutes, passing forever makes the silence indefinite. Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start. """ + await self._get_instance_vars_event.wait() log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.") if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): await ctx.send(f"{Emojis.cross_mark} current channel is already silenced.") @@ -99,6 +102,7 @@ class Silence(commands.Cog): Unsilence a previously silenced `channel`, remove it from notifier of indefinitely silenced channels and cancel the notifier if empty. """ + await self._get_instance_vars_event.wait() log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") if await self._unsilence(ctx.channel): await ctx.send(f"{Emojis.check_mark} unsilenced current channel.") -- cgit v1.2.3 From a6c9b4991dbb4dcdbc1e2abf97d1a167e8cd983c Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Mar 2020 18:10:06 +0100 Subject: Document returns values of private methods. --- bot/cogs/moderation/silence.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 42047d0f7..f532260ca 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -113,6 +113,7 @@ class Silence(commands.Cog): If `persistent` is `True` add `channel` to notifier. `duration` is only used for logging; if None is passed `persistent` should be True to not log None. + Return `True` if channel permissions were changed, `False` otherwise. """ current_overwrite = channel.overwrites_for(self._verified_role) if current_overwrite.send_messages is False: @@ -134,6 +135,7 @@ class Silence(commands.Cog): Check if `channel` is silenced through a `PermissionOverwrite`, if it is unsilence it and remove it from the notifier. + Return `True` if channel permissions were changed, `False` otherwise. """ current_overwrite = channel.overwrites_for(self._verified_role) if current_overwrite.send_messages is False: -- cgit v1.2.3 From 36c57c6f89a070fbb77a641182e37c788b6de7a0 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Mar 2020 18:21:49 +0100 Subject: Adjust tests for new calling behaviour. `.set_permissions` calls were changed to use kwargs directly instead of an overwrite, this reflects the changes in tests. --- tests/bot/cogs/moderation/test_silence.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 6114fee21..b09426fde 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -141,7 +141,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel = MockTextChannel() self.assertTrue(await self.cog._silence(channel, False, None)) channel.set_permissions.assert_called_once() - self.assertFalse(channel.set_permissions.call_args.kwargs['overwrite'].send_messages) + self.assertFalse(channel.set_permissions.call_args.kwargs['send_messages']) async def test_silence_private_notifier(self): """Channel should be added to notifier with `persistent` set to `True`, and the other way around.""" @@ -175,7 +175,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) self.assertTrue(await self.cog._unsilence(channel)) channel.set_permissions.assert_called_once() - self.assertTrue(channel.set_permissions.call_args.kwargs['overwrite'].send_messages) + self.assertTrue(channel.set_permissions.call_args.kwargs['send_messages']) @mock.patch.object(Silence, "notifier", create=True) async def test_unsilence_private_removed_notifier(self, notifier): -- cgit v1.2.3 From 0a2774fadddd18a86822a47599ebc4b76f1e5a7e Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Mar 2020 18:23:11 +0100 Subject: Set `_get_instance_vars_event` in test's `setUp`. --- tests/bot/cogs/moderation/test_silence.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index b09426fde..c6f1fc1da 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -76,6 +76,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.cog = Silence(self.bot) self.ctx = MockContext() self.cog._verified_role = None + # Set event so command callbacks can continue. + self.cog._get_instance_vars_event.set() async def test_instance_vars_got_guild(self): """Bot got guild after it became available.""" -- cgit v1.2.3 From 8eea9c39261d77f86181600164920a635edd3570 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Mon, 16 Mar 2020 00:27:18 +0700 Subject: Fixed tag search via contents, any keywords. Fixed `!tag search any` raises `AttributeError`. Changed default value of `keywords` from `None` to `'any'`. This will make it search for keyword `'any'` when there is no keyword. --- bot/cogs/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 5b820978d..539105017 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -155,7 +155,7 @@ class Tags(Cog): 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: + async def search_tag_content_any_keyword(self, ctx: Context, *, keywords: Optional[str] = 'any') -> None: """ Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma. -- cgit v1.2.3 From 166a4368a786c7aa1446a0348cec8c19307f1e55 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Mar 2020 23:20:03 +0100 Subject: Remove long indentation from docstrings. --- bot/cogs/error_handler.py | 4 ++-- bot/converters.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 73757b7b7..bad6e51a3 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -98,8 +98,8 @@ class ErrorHandler(Cog): Attempt to invoke the silence or unsilence command if invoke with matches a pattern. Respecting the checks if: - invoked with `shh+` silence channel for amount of h's*2 with max of 15. - invoked with `unshh+` unsilence channel + * invoked with `shh+` silence channel for amount of h's*2 with max of 15. + * invoked with `unshh+` unsilence channel Return bool depending on success of command. """ command = ctx.invoked_with.lower() diff --git a/bot/converters.py b/bot/converters.py index 635fef1c7..2b413f039 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -273,10 +273,10 @@ class HushDurationConverter(Converter): If `"forever"` is passed, None is returned; otherwise an int of the extracted time. Accepted formats are: - , - m, - M, - forever. + * , + * m, + * M, + * forever. """ if argument == "forever": return None -- cgit v1.2.3 From 590c26355eb9b490a738afe936820c0e12c34873 Mon Sep 17 00:00:00 2001 From: ks123 Date: Mon, 16 Mar 2020 13:35:31 +0200 Subject: (Mod Log): Fixed case when `on_guild_channel_update` old or new value is empty and with this message formatting go wrong. --- bot/cogs/moderation/modlog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 81d95298d..5d7c91ac4 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -215,7 +215,7 @@ class ModLog(Cog, name="ModLog"): new = value["new_value"] old = value["old_value"] - changes.append(f"**{key.title()}:** `{old}` **→** `{new}`") + changes.append(f"**{key.title()}:** `{old or 'None'}` **→** `{new or 'None'}`") done.append(key) -- cgit v1.2.3 From 65057491b31f798aa82cb1e907fda6685d42eb1d Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 16 Mar 2020 17:11:08 +0100 Subject: Handle and log `CommandErrors` on `.can_run`. --- bot/cogs/error_handler.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index bad6e51a3..6a622d2ce 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -104,7 +104,12 @@ class ErrorHandler(Cog): """ command = ctx.invoked_with.lower() silence_command = self.bot.get_command("silence") - if not await silence_command.can_run(ctx): + ctx.invoked_from_error_handler = True + try: + if not await silence_command.can_run(ctx): + log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.") + return False + except errors.CommandError: log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.") return False if command.startswith("shh"): -- cgit v1.2.3 From 8e60f04048cf9272daf2a2e08eab76a69af97bf4 Mon Sep 17 00:00:00 2001 From: Karlis S <45097959+ks129@users.noreply.github.com> Date: Mon, 16 Mar 2020 18:32:55 +0200 Subject: (Mod Log): Added comment about channel update formatting change. --- bot/cogs/moderation/modlog.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 5d7c91ac4..21eded6e6 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -215,6 +215,8 @@ class ModLog(Cog, name="ModLog"): new = value["new_value"] old = value["old_value"] + # `or` is required here on `old` and `new` due otherwise, when one of them is empty, + # formatting in Discord will break. changes.append(f"**{key.title()}:** `{old or 'None'}` **→** `{new or 'None'}`") done.append(key) -- cgit v1.2.3 From 88d2d85ec114eac2b9e3be9b18e075302f73509e Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Mon, 16 Mar 2020 13:23:29 -0400 Subject: Update explanation comment so it explains what happens --- bot/cogs/moderation/modlog.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 21eded6e6..5f9bc0c6c 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -215,8 +215,9 @@ class ModLog(Cog, name="ModLog"): new = value["new_value"] old = value["old_value"] - # `or` is required here on `old` and `new` due otherwise, when one of them is empty, - # formatting in Discord will break. + # Discord does not treat consecutive backticks ("``") as an empty inline code block, so the markdown + # formatting is broken when `new` and/or `old` are empty values. "None" is used for these cases so + # formatting is preserved. changes.append(f"**{key.title()}:** `{old or 'None'}` **→** `{new or 'None'}`") done.append(key) -- cgit v1.2.3 From b8559cc12fa75dd4b4a52697cf5aa313d3c397d0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 16 Mar 2020 10:27:21 -0700 Subject: Cog tests: comment some code for clarification --- tests/bot/cogs/test_cogs.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index db559ded6..39f6492cb 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -19,6 +19,7 @@ class CommandNameTests(unittest.TestCase): @staticmethod def walk_commands(cog: commands.Cog) -> t.Iterator[commands.Command]: """An iterator that recursively walks through `cog`'s commands and subcommands.""" + # Can't use Bot.walk_commands() or Cog.get_commands() cause those are instance methods. for command in cog.__cog_commands__: if command.parent is None: yield command @@ -32,6 +33,7 @@ class CommandNameTests(unittest.TestCase): def on_error(name: str) -> t.NoReturn: raise ImportError(name=name) + # The mock prevents asyncio.get_event_loop() from being called. with mock.patch("discord.ext.tasks.loop"): for module in pkgutil.walk_packages(cogs.__path__, "bot.cogs.", onerror=on_error): if not module.ispkg: @@ -41,6 +43,7 @@ class CommandNameTests(unittest.TestCase): def walk_cogs(module: ModuleType) -> t.Iterator[commands.Cog]: """Yield all cogs defined in an extension.""" for obj in module.__dict__.values(): + # Check if it's a class type cause otherwise issubclass() may raise a TypeError. is_cog = isinstance(obj, type) and issubclass(obj, commands.Cog) if is_cog and obj.__module__ == module.__name__: yield obj -- cgit v1.2.3 From e32d89ebd1df005046ca2a2a10e413d0a57cd453 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Mon, 16 Mar 2020 13:23:04 -0500 Subject: Nesting reduced, logging cleaned up and made clearer Co-Authored-By: Mark --- bot/cogs/moderation/infractions.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index f68f8ba9a..0545f43bc 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -244,18 +244,21 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user, action) # Remove perma banned users from the watch list - if infraction.get('expires_at') is None: - log.trace("Ban was a permanent one. Attempt to remove from watched list.") - bb_cog = self.bot.get_cog("Big Brother") - if bb_cog: - log.trace("Cog loaded. Attempting to remove from list.") - await bb_cog.apply_unwatch( - ctx, - user, - "User has been permanently banned from the server. Automatically removed.", - banned=True - ) - log.debug("Perma banned user removed from watch list.") + if infraction.get('expires_at') is not None: + log.trace(f"Ban isn't permanent; user {user} won't be unwatched by Big Brother.") + return + + bb_cog = self.bot.get_cog("Big Brother") + if not bb_cog: + log.trace(f"Big Brother cog not loaded; perma-banned user {user} won't be unwatched.") + return + + log.trace(f"Big Brother cog loaded; attempting to unwatch perma-banned user {user}.") + + bb_reason = "User has been permanently banned from the server. Automatically removed." + await bb_cog.apply_unwatch(ctx, user, bb_reason banned=True) + + log.debug(f"Perma-banned user {user} was unwatched.") # endregion # region: Base pardon functions -- cgit v1.2.3 From a184b304a136d6f2e3373e475433caf7665fde6d Mon Sep 17 00:00:00 2001 From: ks123 Date: Tue, 17 Mar 2020 09:09:20 +0200 Subject: (!zen Command): Added exact word check before `difflib`'s matching, due matching may not count exact word as best choice. --- bot/cogs/utils.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 024141d62..2ca2c028e 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -230,7 +230,16 @@ class Utils(Cog): await ctx.send(embed=embed) return - # handle if it's a search string + # Try to handle first exact word due difflib.SequenceMatched may use some other similar word instead + # exact word. + for i, line in enumerate(zen_lines): + if search_value.lower() in line.lower(): + embed.title += f" (line {i}):" + embed.description = line + await ctx.send(embed=embed) + return + + # handle if it's a search string and not exact word matcher = difflib.SequenceMatcher(None, search_value.lower()) best_match = "" -- cgit v1.2.3 From 039a04462be58e9d345e32efcae13c8c999776db Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 17 Mar 2020 13:23:44 +0100 Subject: Fix `test_cog_unload` passing tests with invalid values. The first assert - `asyncio_mock.create_task.assert_called_once_with` called `alert_channel`'s send resulting in an extra call. `send` on `alert_channel` was not tested properly because of a typo and a missing assert in the method call. --- tests/bot/cogs/moderation/test_silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index c6f1fc1da..febfd584b 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -202,8 +202,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): """Task for sending an alert was created with present `muted_channels`.""" with mock.patch.object(self.cog, "muted_channels"): self.cog.cog_unload() + alert_channel.send.assert_called_once_with(f"<@&{Roles.moderators}> channels left silenced on cog unload: ") asyncio_mock.create_task.assert_called_once_with(alert_channel.send()) - alert_channel.send.called_once_with(f"<@&{Roles.moderators}> chandnels left silenced on cog unload: ") @mock.patch("bot.cogs.moderation.silence.asyncio") def test_cog_unload1(self, asyncio_mock): -- cgit v1.2.3 From 2803c13c477634ceefe3501ad9cb7c76cfecf450 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 17 Mar 2020 17:10:16 +0100 Subject: Rename `cog_unload` tests. Previous names were undescriptive from testing phases. --- tests/bot/cogs/moderation/test_silence.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index febfd584b..07a70e7dc 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -198,7 +198,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): @mock.patch("bot.cogs.moderation.silence.asyncio") @mock.patch.object(Silence, "_mod_alerts_channel", create=True) - def test_cog_unload(self, alert_channel, asyncio_mock): + def test_cog_unload_starts_task(self, alert_channel, asyncio_mock): """Task for sending an alert was created with present `muted_channels`.""" with mock.patch.object(self.cog, "muted_channels"): self.cog.cog_unload() @@ -206,7 +206,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): asyncio_mock.create_task.assert_called_once_with(alert_channel.send()) @mock.patch("bot.cogs.moderation.silence.asyncio") - def test_cog_unload1(self, asyncio_mock): + def test_cog_unload_skips_task_start(self, asyncio_mock): """No task created with no channels.""" self.cog.cog_unload() asyncio_mock.create_task.assert_not_called() -- cgit v1.2.3 From 331cd64c4d874937fad052ff83388e73db3441ee Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 17 Mar 2020 17:13:17 +0100 Subject: Remove `channel` mentions from command docstrings. With the new behaviour of not accepting channels and muting the current one, it's no longer neccessary to keep the channel param in the docstring. Co-authored-by: MarkKoz --- bot/cogs/moderation/silence.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index f532260ca..552914ae8 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -75,7 +75,7 @@ class Silence(commands.Cog): @commands.command(aliases=("hush",)) async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None: """ - Silence `channel` for `duration` minutes or `forever`. + Silence the current channel for `duration` minutes or `forever`. Duration is capped at 15 minutes, passing forever makes the silence indefinite. Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start. @@ -97,7 +97,7 @@ class Silence(commands.Cog): @commands.command(aliases=("unhush",)) async def unsilence(self, ctx: Context) -> None: """ - Unsilence `channel`. + Unsilence the current channel. Unsilence a previously silenced `channel`, remove it from notifier of indefinitely silenced channels and cancel the notifier if empty. -- cgit v1.2.3 From 55de2566581fbc2696c932ed57c5600e9b19f1c9 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 17 Mar 2020 17:14:00 +0100 Subject: Reword `unsilence` docstring. Co-authored-by: MarkKoz --- bot/cogs/moderation/silence.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 552914ae8..1523baf11 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -99,8 +99,7 @@ class Silence(commands.Cog): """ Unsilence the current channel. - Unsilence a previously silenced `channel`, - remove it from notifier of indefinitely silenced channels and cancel the notifier if empty. + If the channel was silenced indefinitely, notifications for the channel will stop. """ await self._get_instance_vars_event.wait() log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") -- cgit v1.2.3 From 20c41f2c5af6fd716c3e7f15de412f7f16f5ff1e Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 17 Mar 2020 17:15:10 +0100 Subject: Remove one indentation level. Co-authored-by: MarkKoz --- tests/bot/cogs/moderation/test_silence.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 07a70e7dc..8b9e30cfe 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -115,9 +115,9 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): ) for duration, result_message, _silence_patch_return in test_cases: with self.subTest( - silence_duration=duration, - result_message=result_message, - starting_unsilenced_state=_silence_patch_return + silence_duration=duration, + result_message=result_message, + starting_unsilenced_state=_silence_patch_return ): with mock.patch.object(self.cog, "_silence", return_value=_silence_patch_return): await self.cog.silence.callback(self.cog, self.ctx, duration) -- cgit v1.2.3 From d456e40ac97a38ee99561546bcafb6aa94117cb7 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 17 Mar 2020 17:16:31 +0100 Subject: Remove `alert_channel` mention from docstring. After removing the optional channel arg and changing output message channels we're only testing `ctx`'s `send`. --- tests/bot/cogs/moderation/test_silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 8b9e30cfe..b4a34bbc7 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -125,7 +125,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.ctx.reset_mock() async def test_unsilence_sent_correct_discord_message(self): - """Check if proper message was sent to `alert_chanel`.""" + """Proper reply after a successful unsilence.""" with mock.patch.object(self.cog, "_unsilence", return_value=True): await self.cog.unsilence.callback(self.cog, self.ctx) self.ctx.send.assert_called_once_with(f"{Emojis.check_mark} unsilenced current channel.") -- cgit v1.2.3 From 95dae9bc7a7519c723539382848c02b9748d067f Mon Sep 17 00:00:00 2001 From: ks123 Date: Tue, 17 Mar 2020 19:32:48 +0200 Subject: (!zen Command): Under exact word match, change matching way from substring to sentence split iterate and equality check. --- bot/cogs/utils.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 2ca2c028e..0619296ad 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -233,11 +233,12 @@ class Utils(Cog): # Try to handle first exact word due difflib.SequenceMatched may use some other similar word instead # exact word. for i, line in enumerate(zen_lines): - if search_value.lower() in line.lower(): - embed.title += f" (line {i}):" - embed.description = line - await ctx.send(embed=embed) - return + for word in line.split(): + if word.lower() == search_value.lower(): + embed.title += f" (line {i}):" + embed.description = line + await ctx.send(embed=embed) + return # handle if it's a search string and not exact word matcher = difflib.SequenceMatcher(None, search_value.lower()) -- cgit v1.2.3 From 386c93a6f18adbf84691f17b13f5113800a353ae Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 17 Mar 2020 19:40:27 +0100 Subject: Fix test name. `removed` was describing the opposite behaviour. --- tests/bot/cogs/moderation/test_silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index b4a34bbc7..55193e2f8 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -158,7 +158,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._silence(channel, False, None) self.cog.notifier.add_channel.assert_not_called() - async def test_silence_private_removed_muted_channel(self): + async def test_silence_private_added_muted_channel(self): channel = MockTextChannel() with mock.patch.object(self.cog, "muted_channels") as muted_channels: await self.cog._silence(channel, False, None) -- cgit v1.2.3 From dced6fdf5f571b82bc975dd3159af57c6f9a12b3 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 17 Mar 2020 19:41:13 +0100 Subject: Add docstring to test. --- tests/bot/cogs/moderation/test_silence.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 55193e2f8..71541086d 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -159,6 +159,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.cog.notifier.add_channel.assert_not_called() async def test_silence_private_added_muted_channel(self): + """Channel was added to `muted_channels` on silence.""" channel = MockTextChannel() with mock.patch.object(self.cog, "muted_channels") as muted_channels: await self.cog._silence(channel, False, None) -- cgit v1.2.3 From c68b943708eaca110ddfa6121872513a422bbef4 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 17 Mar 2020 20:10:50 +0100 Subject: Use set `discard` instead of `remove`. Discard ignores non present values, allowing us to skip the KeyError suppress. --- bot/cogs/moderation/silence.py | 3 +-- tests/bot/cogs/moderation/test_silence.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 1523baf11..a1446089e 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -141,8 +141,7 @@ class Silence(commands.Cog): await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=True)) log.info(f"Unsilenced channel #{channel} ({channel.id}).") self.notifier.remove_channel(channel) - with suppress(KeyError): - self.muted_channels.remove(channel) + self.muted_channels.discard(channel) return True log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") return False diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 71541086d..eee020455 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -195,7 +195,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) with mock.patch.object(self.cog, "muted_channels") as muted_channels: await self.cog._unsilence(channel) - muted_channels.remove.assert_called_once_with(channel) + muted_channels.discard.assert_called_once_with(channel) @mock.patch("bot.cogs.moderation.silence.asyncio") @mock.patch.object(Silence, "_mod_alerts_channel", create=True) -- cgit v1.2.3 From cd429230fcb18c7101afd931317d37ad142bfe4b Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 17 Mar 2020 20:11:44 +0100 Subject: Add tests ensuring permissions get preserved. --- tests/bot/cogs/moderation/test_silence.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index eee020455..44682a1bd 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -2,6 +2,8 @@ import unittest from unittest import mock from unittest.mock import MagicMock, Mock +from discord import PermissionOverwrite + from bot.cogs.moderation.silence import Silence, SilenceNotifier from bot.constants import Channels, Emojis, Guild, Roles from tests.helpers import MockBot, MockContext, MockTextChannel @@ -145,6 +147,20 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel.set_permissions.assert_called_once() self.assertFalse(channel.set_permissions.call_args.kwargs['send_messages']) + async def test_silence_private_preserves_permissions(self): + """Previous permissions were preserved when channel was silenced.""" + channel = MockTextChannel() + # Set up mock channel permission state. + mock_permissions = PermissionOverwrite() + mock_permissions_dict = dict(mock_permissions) + channel.overwrites_for.return_value = mock_permissions + await self.cog._silence(channel, False, None) + new_permissions = channel.set_permissions.call_args.kwargs + # Remove 'send_messages' key because it got changed in the method. + del new_permissions['send_messages'] + del mock_permissions_dict['send_messages'] + self.assertDictEqual(mock_permissions_dict, new_permissions) + async def test_silence_private_notifier(self): """Channel should be added to notifier with `persistent` set to `True`, and the other way around.""" channel = MockTextChannel() @@ -197,6 +213,21 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._unsilence(channel) muted_channels.discard.assert_called_once_with(channel) + @mock.patch.object(Silence, "notifier", create=True) + async def test_unsilence_private_preserves_permissions(self, _): + """Previous permissions were preserved when channel was unsilenced.""" + channel = MockTextChannel() + # Set up mock channel permission state. + mock_permissions = PermissionOverwrite(send_messages=False) + mock_permissions_dict = dict(mock_permissions) + channel.overwrites_for.return_value = mock_permissions + await self.cog._unsilence(channel) + new_permissions = channel.set_permissions.call_args.kwargs + # Remove 'send_messages' key because it got changed in the method. + del new_permissions['send_messages'] + del mock_permissions_dict['send_messages'] + self.assertDictEqual(mock_permissions_dict, new_permissions) + @mock.patch("bot.cogs.moderation.silence.asyncio") @mock.patch.object(Silence, "_mod_alerts_channel", create=True) def test_cog_unload_starts_task(self, alert_channel, asyncio_mock): -- cgit v1.2.3 From cefcc575b6faa94fb18f1985f039125d023b2580 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 17 Mar 2020 22:18:58 +0100 Subject: Add tests for `HushDurationConverter`. --- tests/bot/test_converters.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py index 1e5ca62ae..ca8cb6825 100644 --- a/tests/bot/test_converters.py +++ b/tests/bot/test_converters.py @@ -8,6 +8,7 @@ from discord.ext.commands import BadArgument from bot.converters import ( Duration, + HushDurationConverter, ISODateTime, TagContentConverter, TagNameConverter, @@ -271,3 +272,32 @@ class ConverterTests(unittest.TestCase): exception_message = f"`{datetime_string}` is not a valid ISO-8601 datetime string" with self.assertRaises(BadArgument, msg=exception_message): asyncio.run(converter.convert(self.context, datetime_string)) + + def test_hush_duration_converter_for_valid(self): + """HushDurationConverter returns correct value for minutes duration or `"forever"` strings.""" + test_values = ( + ("0", 0), + ("15", 15), + ("10", 10), + ("5m", 5), + ("5M", 5), + ("forever", None), + ) + converter = HushDurationConverter() + for minutes_string, expected_minutes in test_values: + with self.subTest(minutes_string=minutes_string, expected_minutes=expected_minutes): + converted = asyncio.run(converter.convert(self.context, minutes_string)) + self.assertEqual(expected_minutes, converted) + + def test_hush_duration_converter_for_invalid(self): + """HushDurationConverter raises correct exception for invalid minutes duration strings.""" + test_values = ( + ("16", "Duration must be at most 15 minutes."), + ("10d", "10d is not a valid minutes duration."), + ("-1", "-1 is not a valid minutes duration."), + ) + converter = HushDurationConverter() + for invalid_minutes_string, exception_message in test_values: + with self.subTest(invalid_minutes_string=invalid_minutes_string, exception_message=exception_message): + with self.assertRaisesRegex(BadArgument, exception_message): + asyncio.run(converter.convert(self.context, invalid_minutes_string)) -- cgit v1.2.3 From f39b2ebbb09d31a4dad0f5436c7bf450685f8d59 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Fri, 20 Mar 2020 12:04:41 -0500 Subject: Updated doc strings to be more descriptive Co-Authored-By: Mark --- bot/cogs/watchchannels/bigbrother.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index caae793bb..fbc779bcc 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -61,7 +61,12 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await self.apply_unwatch(ctx, user, reason) async def apply_watch(self, ctx: Context, user: FetchedMember, reason: str) -> None: - """Handles adding a user to the watch list.""" + """ + Add `user` to watched users and apply a watch infraction with `reason`. + + A message indicating the result of the operation is sent to `ctx`. + The message will include `user`'s previous watch infraction history, if it exists. + """ if user.bot: await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") return @@ -101,7 +106,12 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await ctx.send(msg) async def apply_unwatch(self, ctx: Context, user: FetchedMember, reason: str, banned: bool = False) -> None: - """Handles the actual user removal from the watch list.""" + """ + Remove `user` from watched users and mark their infraction as inactive with `reason`. + + If `send_message` is True, a message indicating the result of the operation is sent to + `ctx`. + """ active_watches = await self.bot.api_client.get( self.api_endpoint, params=ChainMap( -- cgit v1.2.3 From 8a983d20c705ad07902ac4f3af54b952575b25ba Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Fri, 20 Mar 2020 13:20:35 -0500 Subject: Updated Docstrings, parameters, and log messages - Docstrings for `apply_ban()` have been edited to mention that the method also removes a banned user from the watch list. - Parameter `banned` in `apply_unwatch()` was changed to `send_message` in order to be more general. Boolean logic was swapped to coincide with that change. - `apply_unwatch()`'s sent message moved to the bottom of the method for clarity. Added `return`s to the method to exit early if no message needs to be sent. Signed-off-by: Daniel Brown --- bot/cogs/moderation/infractions.py | 11 ++++++----- bot/cogs/watchchannels/bigbrother.py | 23 +++++++++++++++-------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 0545f43bc..c242a3000 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -230,7 +230,11 @@ class Infractions(InfractionScheduler, commands.Cog): @respect_role_hierarchy() async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: str, **kwargs) -> None: - """Apply a ban infraction with kwargs passed to `post_infraction`.""" + """ + Apply a ban infraction with kwargs passed to `post_infraction`. + + Will also remove the banned user from the Big Brother watch list if applicable. + """ if await utils.has_active_infraction(ctx, user, "ban"): return @@ -243,7 +247,6 @@ class Infractions(InfractionScheduler, commands.Cog): action = ctx.guild.ban(user, reason=reason, delete_message_days=0) await self.apply_infraction(ctx, infraction, user, action) - # Remove perma banned users from the watch list if infraction.get('expires_at') is not None: log.trace(f"Ban isn't permanent; user {user} won't be unwatched by Big Brother.") return @@ -256,9 +259,7 @@ class Infractions(InfractionScheduler, commands.Cog): log.trace(f"Big Brother cog loaded; attempting to unwatch perma-banned user {user}.") bb_reason = "User has been permanently banned from the server. Automatically removed." - await bb_cog.apply_unwatch(ctx, user, bb_reason banned=True) - - log.debug(f"Perma-banned user {user} was unwatched.") + await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False) # endregion # region: Base pardon functions diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index fbc779bcc..903c87f85 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -105,7 +105,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await ctx.send(msg) - async def apply_unwatch(self, ctx: Context, user: FetchedMember, reason: str, banned: bool = False) -> None: + async def apply_unwatch(self, ctx: Context, user: FetchedMember, reason: str, send_message: bool = True) -> None: """ Remove `user` from watched users and mark their infraction as inactive with `reason`. @@ -130,13 +130,20 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await post_infraction(ctx, user, 'watch', f"Unwatched: {reason}", hidden=True, active=False) - if not banned: # Prevents a message being sent to the channel if part of a permanent ban - log.trace("User is not banned. Sending message to channel") - await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed.") - self._remove_user(user.id) + + if not send_message: # Prevents a message being sent to the channel if part of a permanent ban + log.debug(f"Perma-banned user {user} was unwatched.") + return + log.trace("User is not banned. Sending message to channel") + message = f":white_check_mark: Messages sent by {user} will no longer be relayed." + else: log.trace("No active watches found for user.") - if not banned: # Prevents a message being sent to the channel if part of a permanent ban - log.trace("User is not perma banned. Send the error message.") - await ctx.send(":x: The specified user is currently not being watched.") + if not send_message: # Prevents a message being sent to the channel if part of a permanent ban + log.debug(f"{user} was not on the watch list; no removal necessary.") + return + log.trace("User is not perma banned. Send the error message.") + message = ":x: The specified user is currently not being watched." + + await ctx.send(message) -- cgit v1.2.3 From abfaef92a90ff71f8b8f2176327904fda88e3d80 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Mon, 16 Mar 2020 17:41:16 -0400 Subject: Update token filter logging to match expanded detection Log message still used the first regex result (re.search) rather than the expanded approach (re.findall) recently added. --- bot/cogs/token_remover.py | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 547ba8da0..ad6d99e84 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -3,6 +3,7 @@ import binascii import logging import re import struct +import typing as t from datetime import datetime from discord import Colour, Message @@ -53,8 +54,9 @@ class TokenRemover(Cog): See: https://discordapp.com/developers/docs/reference#snowflakes """ - if self.is_token_in_message(msg): - await self.take_action(msg) + found_token = self.find_token_in_message(msg) + if found_token: + await self.take_action(msg, found_token) @Cog.listener() async def on_message_edit(self, before: Message, after: Message) -> None: @@ -63,12 +65,13 @@ class TokenRemover(Cog): See: https://discordapp.com/developers/docs/reference#snowflakes """ - if self.is_token_in_message(after): - await self.take_action(after) + found_token = self.find_token_in_message(after) + if found_token: + await self.take_action(after, found_token) - async def take_action(self, msg: Message) -> None: + async def take_action(self, msg: Message, found_token: str) -> None: """Remove the `msg` containing a token an send a mod_log message.""" - user_id, creation_timestamp, hmac = TOKEN_RE.search(msg.content).group(0).split('.') + user_id, creation_timestamp, hmac = found_token.split('.') self.mod_log.ignore(Event.message_delete, msg.id) await msg.delete() await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) @@ -91,18 +94,21 @@ class TokenRemover(Cog): ) @classmethod - def is_token_in_message(cls, msg: Message) -> bool: - """Check if `msg` contains a seemly valid token.""" + def find_token_in_message(cls, msg: Message) -> t.Optional[str]: + """Check for a seemingly valid token in the provided `Message` instance.""" if msg.author.bot: - return False + return # 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 + for substr in maybe_matches: + if cls.is_maybe_token(substr): + # Short-circuit on first match + return substr - return any(cls.is_maybe_token(substr) for substr in maybe_matches) + # No matching substring + return @classmethod def is_maybe_token(cls, test_str: str) -> bool: -- cgit v1.2.3 From c432bf965a7bd5f660730aa3497ef1f8d8800a31 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Fri, 20 Mar 2020 15:11:15 -0500 Subject: Changed a logging level - Changed the log for when the big brother cog doesn't load in the `apply_ban()` method doesn't properly load from a trace to an error. --- bot/cogs/moderation/infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index c242a3000..efa19f59e 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -253,7 +253,7 @@ class Infractions(InfractionScheduler, commands.Cog): bb_cog = self.bot.get_cog("Big Brother") if not bb_cog: - log.trace(f"Big Brother cog not loaded; perma-banned user {user} won't be unwatched.") + log.error(f"Big Brother cog not loaded; perma-banned user {user} won't be unwatched.") return log.trace(f"Big Brother cog loaded; attempting to unwatch perma-banned user {user}.") -- cgit v1.2.3 From 387d0aa17721460d912e4d05348521d278de72c0 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Fri, 20 Mar 2020 18:45:29 -0400 Subject: Update contributor doc --- CONTRIBUTING.md | 59 ++++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 61d11f844..be591d17e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Contributing to one of our projects +# Contributing to one of Our Projects Our projects are open-source and are automatically deployed whenever commits are pushed to the `master` branch on each repository, so we've created a set of guidelines in order to keep everything clean and in working order. @@ -10,12 +10,12 @@ Note that contributions may be rejected on the basis of a contributor failing to 2. If you have direct access to the repository, **create a branch for your changes** and create a pull request for that branch. If not, create a branch on a fork of the repository and create a pull request from there. * It's common practice for a repository to reject direct pushes to `master`, so make branching a habit! * If PRing from your own fork, **ensure that "Allow edits from maintainers" is checked**. This gives permission for maintainers to commit changes directly to your fork, speeding up the review process. -3. **Adhere to the prevailing code style**, which we enforce using [flake8](http://flake8.pycqa.org/en/latest/index.html). - * Run `flake8` against your code **before** you push it. Your commit will be rejected by the build server if it fails to lint. - * [Git Hooks](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) are a powerful tool that can be a daunting to set up. Fortunately, [`pre-commit`](https://github.com/pre-commit/pre-commit) abstracts this process away from you and is provided as a dev dependency for this project. Run `pipenv run precommit` when setting up the project and you'll never have to worry about breaking the build for linting errors. +3. **Adhere to the prevailing code style**, which we enforce using [`flake8`](http://flake8.pycqa.org/en/latest/index.html) and [`pre-commit`](https://pre-commit.com/). + * Run `flake8` and `pre-commit` against your code [**before** you push it](https://soundcloud.com/lemonsaurusrex/lint-before-you-push). Your commit will be rejected by the build server if it fails to lint. + * [Git Hooks](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) are a powerful git feature for executing custom scripts when certain important git actions occur. The pre-commit hook is the first hook executed during the commit process and can be used to check the code being committed & abort the commit if issues, such as linting failures, are detected. While git hooks can seem daunting to configure, the `pre-commit` framework abstracts this process away from you and is provided as a dev dependency for this project. Run `pipenv run precommit` when setting up the project and you'll never have to worry about committing code that fails linting. 4. **Make great commits**. A well structured git log is key to a project's maintainability; it efficiently provides insight into when and *why* things were done for future maintainers of the project. * Commits should be as narrow in scope as possible. Commits that span hundreds of lines across multiple unrelated functions and/or files are very hard for maintainers to follow. After about a week they'll probably be hard for you to follow too. - * Try to avoid making minor commits for fixing typos or linting errors. Since you've already set up a pre-commit hook to run `flake8` before a commit, you shouldn't be committing linting issues anyway. + * Avoid making minor commits for fixing typos or linting errors. Since you've already set up a `pre-commit` hook to run the linting pipeline before a commit, you shouldn't be committing linting issues anyway. * A more in-depth guide to writing great commit messages can be found in Chris Beam's [*How to Write a Git Commit Message*](https://chris.beams.io/posts/git-commit/) 5. **Avoid frequent pushes to the main repository**. This goes for PRs opened against your fork as well. Our test build pipelines are triggered every time a push to the repository (or PR) is made. Try to batch your commits until you've finished working for that session, or you've reached a point where collaborators need your commits to continue their own work. This also provides you the opportunity to amend commits for minor changes rather than having to commit them on their own because you've already pushed. * This includes merging master into your branch. Try to leave merging from master for after your PR passes review; a maintainer will bring your PR up to date before merging. Exceptions to this include: resolving merge conflicts, needing something that was pushed to master for your branch, or something was pushed to master that could potentionally affect the functionality of what you're writing. @@ -24,13 +24,12 @@ Note that contributions may be rejected on the basis of a contributor failing to * One option is to fork the other contributor's repository and submit your changes to their branch with your own pull request. We suggest following these guidelines when interacting with their repository as well. * The author(s) of inactive PRs and claimed issues will be be pinged after a week of inactivity for an update. Continued inactivity may result in the issue being released back to the community and/or PR closure. 8. **Work as a team** and collaborate wherever possible. Keep things friendly and help each other out - these are shared projects and nobody likes to have their feet trodden on. -9. **Internal projects are internal**. As a contributor, you have access to information that the rest of the server does not. With this trust comes responsibility - do not release any information you have learned as a result of your contributor position. We are very strict about announcing things at specific times, and many staff members will not appreciate a disruption of the announcement schedule. -10. All static content, such as images or audio, **must be licensed for open public use**. +9. All static content, such as images or audio, **must be licensed for open public use**. * Static content must be hosted by a service designed to do so. Failing to do so is known as "leeching" and is frowned upon, as it generates extra bandwidth costs to the host without providing benefit. It would be best if appropriately licensed content is added to the repository itself so it can be served by PyDis' infrastructure. -Above all, the needs of our community should come before the wants of an individual. Work together, build solutions to problems and try to do so in a way that people can learn from easily. Abuse of our trust may result in the loss of your Contributor role, especially in relation to Rule 7. +Above all, the needs of our community should come before the wants of an individual. Work together, build solutions to problems and try to do so in a way that people can learn from easily. Abuse of our trust may result in the loss of your Contributor role. -## Changes to this arrangement +## Changes to this Arrangement All projects evolve over time, and this contribution guide is no different. This document is open to pull requests or changes by contributors. If you believe you have something valuable to add or change, please don't hesitate to do so in a PR. @@ -48,10 +47,14 @@ When pulling down changes from GitHub, remember to sync your environment using ` For example: ```py -def foo(input_1: int, input_2: dict) -> bool: +import typing as t + + +def foo(input_1: int, input_2: t.Dict[str, str]) -> bool: + ... ``` -Tells us that `foo` accepts an `int` and a `dict` and returns a `bool`. +Tells us that `foo` accepts an `int` and a `dict`, with `str` keys and values, and returns a `bool`. All function declarations should be type hinted in code contributed to the PyDis organization. @@ -63,15 +66,19 @@ Many documentation packages provide support for automatic documentation generati For example: ```py -def foo(bar: int, baz: dict=None) -> bool: +import typing as t + + +def foo(bar: int, baz: t.Optional[t.Dict[str, str]] = None) -> bool: """ Does some things with some stuff. :param bar: Some input - :param baz: Optional, some other input + :param baz: Optional, some dictionary with string keys and values :return: Some boolean """ + ... ``` Since PyDis does not utilize automatic documentation generation, use of this syntax should not be used in code contributed to the organization. Should the purpose and type of the input variables not be easily discernable from the variable name and type annotation, a prose explanation can be used. Explicit references to variables, functions, classes, etc. should be wrapped with backticks (`` ` ``). @@ -79,25 +86,33 @@ Since PyDis does not utilize automatic documentation generation, use of this syn For example, the above docstring would become: ```py -def foo(bar: int, baz: dict=None) -> bool: +import typing as t + + +def foo(bar: int, baz: t.Optional[t.Dict[str, str]] = None) -> bool: """ Does some things with some stuff. This function takes an index, `bar` and checks for its presence in the database `baz`, passed as a dictionary. Returns `False` if `baz` is not passed. """ + ... ``` ### Logging Levels -The project currently defines [`logging`](https://docs.python.org/3/library/logging.html) levels as follows: -* **TRACE:** Use this for tracing every step of a complex process. That way we can see which step of the process failed. Err on the side of verbose. **Note:** This is a PyDis-implemented logging level. -* **DEBUG:** Someone is interacting with the application, and the application is behaving as expected. -* **INFO:** Something completely ordinary happened. Like a cog loading during startup. -* **WARNING:** Someone is interacting with the application in an unexpected way or the application is responding in an unexpected way, but without causing an error. -* **ERROR:** An error that affects the specific part that is being interacted with -* **CRITICAL:** An error that affects the whole application. +The project currently defines [`logging`](https://docs.python.org/3/library/logging.html) levels as follows, from lowest to highest severity: +* **TRACE:** These events should be used to provide a *verbose* trace of every step of a complex process. This is essentially the `logging` equivalent of sprinkling `print` statements throughout the code. + * **Note:** This is a PyDis-implemented logging level. +* **DEBUG:** These events should add context to what's happening in a development setup to make it easier to follow what's going while working on a project. This is in the same vein as **TRACE** logging but at a much lower level of verbosity. +* **INFO:** These events are normal and don't need direct attention but are worth keeping track of in production, like checking which cogs were loaded during a start-up. +* **WARNING:** These events are out of the ordinary and should be fixed, but have not caused a failure. + * **NOTE:** Events at this logging level and higher should be reserved for events that require the attention of the DevOps team. +* **ERROR:** These events have caused a failure in a specific part of the application and require urgent attention. +* **CRITICAL:** These events have caused the whole application to fail and require immediate intervention. + +Ensure that log messages are succinct. Should you want to pass additional useful information that would otherwise make the log message overly verbose the `logging` module accepts an `extra` kwarg, which can be used to pass a dictionary. This is used to populate the `__dict__` of the `LogRecord` created for the logging event with user-defined attributes that can be accessed by a log handler. Additional information and caveats may be found [in Python's `logging` documentation](https://docs.python.org/3/library/logging.html#logging.Logger.debug). ### Work in Progress (WIP) PRs -Github [has introduced a new PR feature](https://github.blog/2019-02-14-introducing-draft-pull-requests/) that allows the PR author to mark it as a WIP. This provides both a visual and functional indicator that the contents of the PR are in a draft state and not yet ready for formal review. +Github [provides a PR feature](https://github.blog/2019-02-14-introducing-draft-pull-requests/) that allows the PR author to mark it as a WIP. This provides both a visual and functional indicator that the contents of the PR are in a draft state and not yet ready for formal review. This feature should be utilized in place of the traditional method of prepending `[WIP]` to the PR title. -- cgit v1.2.3 From 3597d22833096754c09e2970a80eff8e6b141132 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Sat, 21 Mar 2020 06:11:25 -0400 Subject: Fix regression in verification cog A stray `bot` was removed from the `on_message` listener, causing it to raise an exception rather than generate a `Context` object from incoming verification channel messages. --- 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 107bc1058..b0a493e68 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -95,7 +95,7 @@ class Verification(Cog): ping_everyone=constants.Filter.ping_everyone, ) - ctx: Context = await self.get_context(message) + ctx: Context = await self.bot.get_context(message) if ctx.command is not None and ctx.command.name == "accept": return -- cgit v1.2.3 From 1ec0a10811c26351e823c29637e16837a761e372 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 16:53:35 -0800 Subject: Resources: add JSON with array of chemical element names --- bot/resources/elements.json | 120 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 bot/resources/elements.json diff --git a/bot/resources/elements.json b/bot/resources/elements.json new file mode 100644 index 000000000..61be9105f --- /dev/null +++ b/bot/resources/elements.json @@ -0,0 +1,120 @@ +[ + "Hydrogen", + "Helium", + "Lithium", + "Beryllium", + "Boron", + "Carbon", + "Nitrogen", + "Oxygen", + "Fluorine", + "Neon", + "Sodium", + "Magnesium", + "Aluminium", + "Silicon", + "Phosphorus", + "Sulfur", + "Chlorine", + "Argon", + "Potassium", + "Calcium", + "Scandium", + "Titanium", + "Vanadium", + "Chromium", + "Manganese", + "Iron", + "Cobalt", + "Nickel", + "Copper", + "Zinc", + "Gallium", + "Germanium", + "Arsenic", + "Selenium", + "Bromine", + "Krypton", + "Rubidium", + "Strontium", + "Yttrium", + "Zirconium", + "Niobium", + "Molybdenum", + "Technetium", + "Ruthenium", + "Rhodium", + "Palladium", + "Silver", + "Cadmium", + "Indium", + "Tin", + "Antimony", + "Tellurium", + "Iodine", + "Xenon", + "Caesium", + "Barium", + "Lanthanum", + "Cerium", + "Praseodymium", + "Neodymium", + "Promethium", + "Samarium", + "Europium", + "Gadolinium", + "Terbium", + "Dysprosium", + "Holmium", + "Erbium", + "Thulium", + "Ytterbium", + "Lutetium", + "Hafnium", + "Tantalum", + "Tungsten", + "Rhenium", + "Osmium", + "Iridium", + "Platinum", + "Gold", + "Mercury", + "Thallium", + "Lead", + "Bismuth", + "Polonium", + "Astatine", + "Radon", + "Francium", + "Radium", + "Actinium", + "Thorium", + "Protactinium", + "Uranium", + "Neptunium", + "Plutonium", + "Americium", + "Curium", + "Berkelium", + "Californium", + "Einsteinium", + "Fermium", + "Mendelevium", + "Nobelium", + "Lawrencium", + "Rutherfordium", + "Dubnium", + "Seaborgium", + "Bohrium", + "Hassium", + "Meitnerium", + "Darmstadtium", + "Roentgenium", + "Copernicium", + "Nihonium", + "Flerovium", + "Moscovium", + "Livermorium", + "Tennessine", + "Oganesson" +] -- cgit v1.2.3 From 3e5bd7328dc3e0d7ae25bb26e087294e4288afe6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 16:58:25 -0800 Subject: HelpChannels: create boilerplate extension and cog --- bot/cogs/help_channels.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 bot/cogs/help_channels.py diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py new file mode 100644 index 000000000..e4febdcaa --- /dev/null +++ b/bot/cogs/help_channels.py @@ -0,0 +1,12 @@ +from discord.ext import commands + +from bot.bot import Bot + + +class HelpChannels(commands.Cog): + """Manage the help channel system of the guild.""" + + +def setup(bot: Bot) -> None: + """Load the HelpChannels cog.""" + bot.add_cog(HelpChannels(bot)) -- cgit v1.2.3 From 439c0dddaecec3da3c804dffda14342ed3ce055d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 17:00:49 -0800 Subject: HelpChannels: load element names from JSON --- bot/cogs/help_channels.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index e4febdcaa..561c4d2c9 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -1,8 +1,15 @@ +import json +from pathlib import Path + from discord.ext import commands from bot.bot import Bot +with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: + ELEMENTS = json.load(elements_file) + + class HelpChannels(commands.Cog): """Manage the help channel system of the guild.""" -- cgit v1.2.3 From 01bf328bea69dfa773d42aab2fb43dcb2b218e0c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 17:22:28 -0800 Subject: Constants: add constants for HelpChannels cog --- bot/constants.py | 8 ++++++++ config-default.yml | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 14f8dc094..4b47db03d 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -532,6 +532,14 @@ class Free(metaclass=YAMLGetter): cooldown_per: float +class HelpChannels(metaclass=YAMLGetter): + section = 'help_channels' + + cmd_whitelist: List[int] + idle_minutes: int + max_available: int + + class Mention(metaclass=YAMLGetter): section = 'mention' diff --git a/config-default.yml b/config-default.yml index 5788d1e12..1f2b12412 100644 --- a/config-default.yml +++ b/config-default.yml @@ -512,6 +512,17 @@ mention: message_timeout: 300 reset_delay: 5 +help_channels: + # Roles which are allowed to use the command which makes channels dormant + cmd_whitelist: + - *HELPERS_ROLE + + # Allowed duration of inactivity before making a channel dormant + idle_minutes: 45 + + # Maximum number of channels to put in the available category + max_available: 2 + redirect_output: delete_invocation: true delete_delay: 15 -- cgit v1.2.3 From 73d218b8bebde2016f0cff215e43e53d8e781608 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 17:32:58 -0800 Subject: HelpChannels: add constants for active/dormant messages --- bot/cogs/help_channels.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 561c4d2c9..6bcaaf624 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -3,8 +3,32 @@ from pathlib import Path from discord.ext import commands +from bot import constants from bot.bot import Bot +ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" + +AVAILABLE_MSG = f""" +This help channel is now **available**, which means that you can claim it by simply typing your \ +question into it. Once claimed, the channel will move into the **Help: In Use** category, and will \ +be yours until it has been inactive for {constants.HelpChannels.idle_minutes}. When that happens, \ +it will be set to **dormant** and moved into the **Help: Dormant** category. + +Try to write the best question you can by providing a detailed description and telling us what \ +you've tried already. For more information on asking a good question, \ +[check out our guide on asking good questions]({ASKING_GUIDE_URL}). +""" + +DORMANT_MSG = f""" +This help channel has been marked as **dormant**, and has been moved into the **Help: Dormant** \ +category at the bottom of the channel list. It is no longer possible to send messages in this \ +channel until it becomes available again. + +If your question wasn't answered yet, you can claim a new help channel from the \ +**Help: Available** category by simply asking your question again. Consider rephrasing the \ +question to maximize your chance of getting a good answer. If you're not sure how, have a look \ +through [our guide for asking a good question]({ASKING_GUIDE_URL}). +""" with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: ELEMENTS = json.load(elements_file) -- cgit v1.2.3 From db185326bace6eb249fc2867472ae7d770f249db Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Feb 2020 18:17:02 -0800 Subject: Constants: add help category constants The original category was re-purposed as the "in-use" category so that deployment of the new system will not interrupt ongoing help sessions. --- bot/cogs/free.py | 2 +- bot/constants.py | 4 +++- config-default.yml | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/bot/cogs/free.py b/bot/cogs/free.py index 33b55e79a..99516fade 100644 --- a/bot/cogs/free.py +++ b/bot/cogs/free.py @@ -19,7 +19,7 @@ PER = Free.cooldown_per class Free(Cog): """Tries to figure out which help channels are free.""" - PYTHON_HELP_ID = Categories.python_help + PYTHON_HELP_ID = Categories.help_in_use @command(name="free", aliases=('f',)) @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES) diff --git a/bot/constants.py b/bot/constants.py index 4b47db03d..58a236546 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -356,7 +356,9 @@ class Categories(metaclass=YAMLGetter): section = "guild" subsection = "categories" - python_help: int + help_available: int + help_in_use: int + help_dormant: int class Channels(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index 1f2b12412..27143ff30 100644 --- a/config-default.yml +++ b/config-default.yml @@ -111,7 +111,9 @@ guild: id: 267624335836053506 categories: - python_help: 356013061213126657 + help_available: 691405807388196926 + help_in_use: 356013061213126657 + help_dormant: 691405908919451718 channels: announcements: 354619224620138496 -- cgit v1.2.3 From de9193d2685ebc4e1cf081003b278ef2ad3cee13 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 11:41:51 -0800 Subject: HelpChannels: add method stubs --- bot/cogs/help_channels.py | 49 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 6bcaaf624..eeb3f3684 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -1,10 +1,14 @@ +import asyncio import json +from collections import deque from pathlib import Path +import discord from discord.ext import commands from bot import constants from bot.bot import Bot +from bot.utils.scheduling import Scheduler ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" @@ -34,9 +38,52 @@ with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file ELEMENTS = json.load(elements_file) -class HelpChannels(commands.Cog): +class HelpChannels(Scheduler, commands.Cog): """Manage the help channel system of the guild.""" + def __init__(self, bot: Bot): + super().__init__() + + self.bot = bot + + async def create_channel_queue(self) -> asyncio.Queue: + """Return a queue of dormant channels to use for getting the next available channel.""" + + async def create_dormant(self) -> discord.TextChannel: + """Create and return a new channel in the Dormant category.""" + + async def create_name_queue(self) -> deque: + """Return a queue of element names to use for creating new channels.""" + + @commands.command(name="dormant") + async def dormant_command(self) -> None: + """Make the current in-use help channel dormant.""" + + async def get_available_candidate(self) -> discord.TextChannel: + """Return a dormant channel to turn into an available channel.""" + + async def get_idle_time(self, channel: discord.TextChannel) -> int: + """Return the time elapsed since the last message sent in the `channel`.""" + + async def init_available(self) -> None: + """Initialise the Available category with channels.""" + + async def move_idle_channels(self) -> None: + """Make all idle in-use channels dormant.""" + + async def move_to_available(self) -> None: + """Make a channel available.""" + + async def move_to_dormant(self, channel: discord.TextChannel) -> None: + """Make the `channel` dormant.""" + + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Move an available channel to the In Use category and replace it with a dormant one.""" + + async def _scheduled_task(self, channel: discord.TextChannel, timeout: int) -> None: + """Make the `channel` dormant after `timeout` seconds or reschedule if it's still active.""" + def setup(bot: Bot) -> None: """Load the HelpChannels cog.""" -- cgit v1.2.3 From c4abc45ee21e0070b38f89c4417efd0c0982ea31 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 11:58:28 -0800 Subject: HelpChannels: add a logger --- bot/cogs/help_channels.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index eeb3f3684..a75314f62 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -1,5 +1,6 @@ import asyncio import json +import logging from collections import deque from pathlib import Path @@ -10,6 +11,8 @@ from bot import constants from bot.bot import Bot from bot.utils.scheduling import Scheduler +log = logging.getLogger(__name__) + ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" AVAILABLE_MSG = f""" -- cgit v1.2.3 From 61be5a13eb6c93dc689cc0dad13206d139c8ad89 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 12:10:37 -0800 Subject: HelpChannels: add a function to get a channel or fetch it from API --- bot/cogs/help_channels.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index a75314f62..5f5129149 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -84,6 +84,14 @@ class HelpChannels(Scheduler, commands.Cog): async def on_message(self, message: discord.Message) -> None: """Move an available channel to the In Use category and replace it with a dormant one.""" + async def try_get_channel(self, channel_id: int) -> discord.abc.GuildChannel: + """Attempt to get or fetch a channel and return it.""" + channel = self.bot.get_channel(channel_id) + if not channel: + channel = await self.bot.fetch_channel(channel_id) + + return channel + async def _scheduled_task(self, channel: discord.TextChannel, timeout: int) -> None: """Make the `channel` dormant after `timeout` seconds or reschedule if it's still active.""" -- cgit v1.2.3 From 2cb8db6172a0c273f2b5768483ecfc42edc8ef9c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 12:18:00 -0800 Subject: HelpChannels: add a function to init the categories As the categories are essential for the functionality of the cog, if this function fails to get a category, it will remove/unload the cog. --- bot/cogs/help_channels.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 5f5129149..5ca16fd41 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -49,6 +49,10 @@ class HelpChannels(Scheduler, commands.Cog): self.bot = bot + self.available_category: discord.CategoryChannel = None + self.in_use_category: discord.CategoryChannel = None + self.dormant_category: discord.CategoryChannel = None + async def create_channel_queue(self) -> asyncio.Queue: """Return a queue of dormant channels to use for getting the next available channel.""" @@ -71,6 +75,18 @@ class HelpChannels(Scheduler, commands.Cog): async def init_available(self) -> None: """Initialise the Available category with channels.""" + async def init_categories(self) -> None: + """Get the help category objects. Remove the cog if retrieval fails.""" + try: + self.available_category = await self.try_get_channel( + constants.Categories.help_available + ) + self.in_use_category = await self.try_get_channel(constants.Categories.help_in_use) + self.dormant_category = await self.try_get_channel(constants.Categories.help_dormant) + except discord.HTTPException: + log.exception(f"Failed to get a category; cog will be removed") + self.bot.remove_cog(self.qualified_name) + async def move_idle_channels(self) -> None: """Make all idle in-use channels dormant.""" -- cgit v1.2.3 From 67fd115fd95a003b0b248385a4175380a8959b1d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 12:24:12 -0800 Subject: HelpChannels: add a function to initialise the cog It's created as a task in __init__ because coroutines cannot be awaited in there. --- bot/cogs/help_channels.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 5ca16fd41..1e99f16b5 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -53,6 +53,11 @@ class HelpChannels(Scheduler, commands.Cog): self.in_use_category: discord.CategoryChannel = None self.dormant_category: discord.CategoryChannel = None + self.channel_queue: asyncio.Queue = None + self.name_queue: deque = None + + asyncio.create_task(self.init_cog()) + async def create_channel_queue(self) -> asyncio.Queue: """Return a queue of dormant channels to use for getting the next available channel.""" @@ -87,6 +92,18 @@ class HelpChannels(Scheduler, commands.Cog): log.exception(f"Failed to get a category; cog will be removed") self.bot.remove_cog(self.qualified_name) + async def init_cog(self) -> None: + """Initialise the help channel system.""" + await self.bot.wait_until_guild_available() + + await self.init_categories() + + self.channel_queue = await self.create_channel_queue() + self.name_queue = await self.name_queue() + + await self.init_available() + await self.move_idle_channels() + async def move_idle_channels(self) -> None: """Make all idle in-use channels dormant.""" -- cgit v1.2.3 From 4d7d29aef45d78a648deba4554d70eeff9691a4c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 12:25:29 -0800 Subject: HelpChannels: cancel the init task when unloading the cog This will prevent initialisation from proceeding when the category channels fail to be retrieved. --- bot/cogs/help_channels.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 1e99f16b5..3865183b0 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -56,7 +56,11 @@ class HelpChannels(Scheduler, commands.Cog): self.channel_queue: asyncio.Queue = None self.name_queue: deque = None - asyncio.create_task(self.init_cog()) + self.init_task = asyncio.create_task(self.init_cog()) + + async def cog_unload(self) -> None: + """Cancel the init task if the cog unloads.""" + self.init_task.cancel() async def create_channel_queue(self) -> asyncio.Queue: """Return a queue of dormant channels to use for getting the next available channel.""" -- cgit v1.2.3 From c5892c76f257cf14ddfad5cb4ccf47ff86623a47 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 12:27:02 -0800 Subject: HelpChannels: set a ready event when cog initialisation completes --- bot/cogs/help_channels.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 3865183b0..6dd689727 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -56,6 +56,7 @@ class HelpChannels(Scheduler, commands.Cog): self.channel_queue: asyncio.Queue = None self.name_queue: deque = None + self.ready = asyncio.Event() self.init_task = asyncio.create_task(self.init_cog()) async def cog_unload(self) -> None: @@ -108,6 +109,8 @@ class HelpChannels(Scheduler, commands.Cog): await self.init_available() await self.move_idle_channels() + self.ready.set() + async def move_idle_channels(self) -> None: """Make all idle in-use channels dormant.""" -- cgit v1.2.3 From 603b1c6e1ac02759e54a50ab892d3c529d10fa2e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 12:41:36 -0800 Subject: HelpChannels: add a function to return used channel names --- bot/cogs/help_channels.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 6dd689727..388bb1390 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -1,6 +1,8 @@ import asyncio +import itertools import json import logging +import typing as t from collections import deque from pathlib import Path @@ -79,6 +81,17 @@ class HelpChannels(Scheduler, commands.Cog): async def get_available_candidate(self) -> discord.TextChannel: """Return a dormant channel to turn into an available channel.""" + def get_used_names(self) -> t.Set[str]: + """Return channels names which are already being used.""" + start_index = len("help-") + channels = itertools.chain( + self.available_category.channels, + self.in_use_category.channels, + self.dormant_category.channels, + ) + + return {c.name[start_index:] for c in channels} + async def get_idle_time(self, channel: discord.TextChannel) -> int: """Return the time elapsed since the last message sent in the `channel`.""" -- cgit v1.2.3 From 0e1f40b1b0155cc16529bb13585021789695e2db Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 12:43:57 -0800 Subject: HelpChannels: implement create_name_queue It returns a queue of element names to use for creating new channels, taking into account which names are already being used. --- bot/cogs/help_channels.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 388bb1390..edc15607a 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -71,8 +71,12 @@ class HelpChannels(Scheduler, commands.Cog): async def create_dormant(self) -> discord.TextChannel: """Create and return a new channel in the Dormant category.""" - async def create_name_queue(self) -> deque: + def create_name_queue(self) -> deque: """Return a queue of element names to use for creating new channels.""" + used_names = self.get_used_names() + available_names = (name for name in ELEMENTS if name not in used_names) + + return deque(available_names) @commands.command(name="dormant") async def dormant_command(self) -> None: -- cgit v1.2.3 From 8d968529cd27b61dbc12f41f96d850f0ddeab66b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 13:02:05 -0800 Subject: HelpChannels: retrieve category channels more efficiently The channels property of categories sorts the channels before returning them. * Add a generator function to get category channels --- bot/cogs/help_channels.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index edc15607a..1ba435308 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -1,5 +1,4 @@ import asyncio -import itertools import json import logging import typing as t @@ -85,16 +84,25 @@ class HelpChannels(Scheduler, commands.Cog): async def get_available_candidate(self) -> discord.TextChannel: """Return a dormant channel to turn into an available channel.""" + @staticmethod + def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: + """Yield the channels of the `category` in an unsorted manner.""" + # This is faster than using category.channels because the latter sorts them. + for channel in category.guild.channels: + if channel.category_id == category.id: + yield channel + def get_used_names(self) -> t.Set[str]: """Return channels names which are already being used.""" start_index = len("help-") - channels = itertools.chain( - self.available_category.channels, - self.in_use_category.channels, - self.dormant_category.channels, - ) - return {c.name[start_index:] for c in channels} + names = set() + for cat in (self.available_category, self.in_use_category, self.dormant_category): + for channel in self.get_category_channels(cat): + name = channel.name[start_index:] + names.add(name) + + return names async def get_idle_time(self, channel: discord.TextChannel) -> int: """Return the time elapsed since the last message sent in the `channel`.""" -- cgit v1.2.3 From b64524d56a407a21f85b08b7ae7147fa13283543 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 13:08:19 -0800 Subject: HelpChannels: only yield text channels from a category --- bot/cogs/help_channels.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 1ba435308..f7af5d3be 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -86,10 +86,10 @@ class HelpChannels(Scheduler, commands.Cog): @staticmethod def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: - """Yield the channels of the `category` in an unsorted manner.""" + """Yield the text channels of the `category` in an unsorted manner.""" # This is faster than using category.channels because the latter sorts them. for channel in category.guild.channels: - if channel.category_id == category.id: + if channel.category_id == category.id and isinstance(channel, discord.TextChannel): yield channel def get_used_names(self) -> t.Set[str]: -- cgit v1.2.3 From db482932088a3d648079cf1b04e0dc89f2602105 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 13:11:56 -0800 Subject: HelpChannels: implement create_channel_queue It returns a queue of dormant channels in random order. The queue will be used to get the next available channel. Using a random order is simpler than trying to sort by the timestamp of the most recent message in each channel and this decision will only "negatively" impact the system when the bot restarts or the extension is reloaded. Ultimately, it just means in such events some dormant channels may chosen to become active again sooner than expected. --- bot/cogs/help_channels.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index f7af5d3be..3d7ece909 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -1,6 +1,7 @@ import asyncio import json import logging +import random import typing as t from collections import deque from pathlib import Path @@ -64,8 +65,20 @@ class HelpChannels(Scheduler, commands.Cog): """Cancel the init task if the cog unloads.""" self.init_task.cancel() - async def create_channel_queue(self) -> asyncio.Queue: - """Return a queue of dormant channels to use for getting the next available channel.""" + def create_channel_queue(self) -> asyncio.Queue: + """ + Return a queue of dormant channels to use for getting the next available channel. + + The channels are added to the queue in a random order. + """ + channels = list(self.get_category_channels(self.dormant_category)) + random.shuffle(channels) + + queue = asyncio.Queue() + for channel in channels: + queue.put_nowait(channel) + + return queue async def create_dormant(self) -> discord.TextChannel: """Create and return a new channel in the Dormant category.""" -- cgit v1.2.3 From ca995f96292d8ff334696e89353e977999f4b6b0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 13:23:51 -0800 Subject: Constants: add a help channel name prefix constant --- bot/cogs/help_channels.py | 2 +- bot/constants.py | 1 + config-default.yml | 3 +++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 3d7ece909..026cb1f78 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -107,7 +107,7 @@ class HelpChannels(Scheduler, commands.Cog): def get_used_names(self) -> t.Set[str]: """Return channels names which are already being used.""" - start_index = len("help-") + start_index = len(constants.HelpChannels.name_prefix) names = set() for cat in (self.available_category, self.in_use_category, self.dormant_category): diff --git a/bot/constants.py b/bot/constants.py index 58a236546..5b50050d6 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -540,6 +540,7 @@ class HelpChannels(metaclass=YAMLGetter): cmd_whitelist: List[int] idle_minutes: int max_available: int + name_prefix: str class Mention(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index 27143ff30..c095aa30b 100644 --- a/config-default.yml +++ b/config-default.yml @@ -525,6 +525,9 @@ help_channels: # Maximum number of channels to put in the available category max_available: 2 + # Prefix for help channel names + name_prefix: 'help-' + redirect_output: delete_invocation: true delete_delay: 15 -- cgit v1.2.3 From 72e564a6704850a54831aab5c4d9d777a21a4f27 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 14:30:55 -0800 Subject: Constants: implement init_available Initialises the Available category with channels if any are missing. --- bot/cogs/help_channels.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 026cb1f78..06520fc08 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -122,6 +122,11 @@ class HelpChannels(Scheduler, commands.Cog): async def init_available(self) -> None: """Initialise the Available category with channels.""" + channels = list(self.get_category_channels(self.available_category)) + missing = constants.HelpChannels.max_available - len(channels) + + for _ in range(missing): + await self.move_to_available() async def init_categories(self) -> None: """Get the help category objects. Remove the cog if retrieval fails.""" -- cgit v1.2.3 From 7c4b776847e7c857c09d43a2434d1187bbb354b5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 14:45:06 -0800 Subject: Constants: add a named tuple for scheduled task data --- bot/cogs/help_channels.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 06520fc08..c440d166c 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -43,6 +43,13 @@ with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file ELEMENTS = json.load(elements_file) +class ChannelTimeout(t.NamedTuple): + """Data for a task scheduled to make a channel dormant.""" + + channel: discord.TextChannel + timeout: int + + class HelpChannels(Scheduler, commands.Cog): """Manage the help channel system of the guild.""" @@ -175,8 +182,8 @@ class HelpChannels(Scheduler, commands.Cog): return channel - async def _scheduled_task(self, channel: discord.TextChannel, timeout: int) -> None: - """Make the `channel` dormant after `timeout` seconds or reschedule if it's still active.""" + async def _scheduled_task(self, data: ChannelTimeout) -> None: + """Make a channel dormant after specified timeout or reschedule if it's still active.""" def setup(bot: Bot) -> None: -- cgit v1.2.3 From cef96afb64d0456e1b60b9be68fb0352bd0191a1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 14:55:13 -0800 Subject: HelpChannels: implement move_idle_channels Make all in-use channels dormant if idle or schedule the move if still active. This is intended to clean up the in-use channels when the bot restarts and has lost the tasks it had scheduled in another life. --- bot/cogs/help_channels.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index c440d166c..c8c437145 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -162,7 +162,16 @@ class HelpChannels(Scheduler, commands.Cog): self.ready.set() async def move_idle_channels(self) -> None: - """Make all idle in-use channels dormant.""" + """Make all in-use channels dormant if idle or schedule the move if still active.""" + idle_seconds = constants.HelpChannels.idle_minutes * 60 + + for channel in self.get_category_channels(self.in_use_category): + time_elapsed = await self.get_idle_time(channel) + if time_elapsed > idle_seconds: + await self.move_to_dormant(channel) + else: + data = ChannelTimeout(channel, idle_seconds - time_elapsed) + self.schedule_task(self.bot.loop, channel.id, data) async def move_to_available(self) -> None: """Make a channel available.""" -- cgit v1.2.3 From 6f9167b3cc016b55265e9692c930924a751a3e10 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 14:56:53 -0800 Subject: HelpChannels: fix creation of queues in init_cog * Remove await from create_channel_queue * Call the correct function to create the name queue --- bot/cogs/help_channels.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index c8c437145..3757f0581 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -153,8 +153,8 @@ class HelpChannels(Scheduler, commands.Cog): await self.init_categories() - self.channel_queue = await self.create_channel_queue() - self.name_queue = await self.name_queue() + self.channel_queue = self.create_channel_queue() + self.name_queue = self.create_name_queue() await self.init_available() await self.move_idle_channels() -- cgit v1.2.3 From 6c57fc1d6581da53394871c9967f7de2fc0ec25f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 15:08:05 -0800 Subject: HelpChannels: make move_idle_channels only handle a single channel This function will get re-used in _scheduled_task, but it will only need to move a single channel. Therefore, to promote code re-use, this change was made. The init_cog will instead do a loop to call this on all channels in the in-use category. --- bot/cogs/help_channels.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 3757f0581..7fe81d407 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -157,21 +157,22 @@ class HelpChannels(Scheduler, commands.Cog): self.name_queue = self.create_name_queue() await self.init_available() - await self.move_idle_channels() + + for channel in self.get_category_channels(self.in_use_category): + await self.move_idle_channel(channel) self.ready.set() - async def move_idle_channels(self) -> None: - """Make all in-use channels dormant if idle or schedule the move if still active.""" + async def move_idle_channel(self, channel: discord.TextChannel) -> None: + """Make the `channel` dormant if idle or schedule the move if still active.""" idle_seconds = constants.HelpChannels.idle_minutes * 60 + time_elapsed = await self.get_idle_time(channel) - for channel in self.get_category_channels(self.in_use_category): - time_elapsed = await self.get_idle_time(channel) - if time_elapsed > idle_seconds: - await self.move_to_dormant(channel) - else: - data = ChannelTimeout(channel, idle_seconds - time_elapsed) - self.schedule_task(self.bot.loop, channel.id, data) + if time_elapsed > idle_seconds: + await self.move_to_dormant(channel) + else: + data = ChannelTimeout(channel, idle_seconds - time_elapsed) + self.schedule_task(self.bot.loop, channel.id, data) async def move_to_available(self) -> None: """Make a channel available.""" -- cgit v1.2.3 From b1aef7df897bdc7f1775e623a57052768557649a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 15:12:25 -0800 Subject: HelpChannels: implement get_idle_time A design change was made to account for a channel being empty i.e. no messages ever sent. In such case, the function will return None. * Move a channel to the Dormant category if the channel has no messages --- bot/cogs/help_channels.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 7fe81d407..a848a3029 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -4,6 +4,7 @@ import logging import random import typing as t from collections import deque +from datetime import datetime from pathlib import Path import discord @@ -124,8 +125,19 @@ class HelpChannels(Scheduler, commands.Cog): return names - async def get_idle_time(self, channel: discord.TextChannel) -> int: - """Return the time elapsed since the last message sent in the `channel`.""" + @staticmethod + async def get_idle_time(channel: discord.TextChannel) -> t.Optional[int]: + """ + Return the time elapsed, in seconds, since the last message sent in the `channel`. + + Return None if the channel has no messages. + """ + try: + msg = await channel.history(limit=1).next() # noqa: B305 + except discord.NoMoreItems: + return None + + return (datetime.utcnow() - msg.created_at).seconds async def init_available(self) -> None: """Initialise the Available category with channels.""" @@ -168,7 +180,7 @@ class HelpChannels(Scheduler, commands.Cog): idle_seconds = constants.HelpChannels.idle_minutes * 60 time_elapsed = await self.get_idle_time(channel) - if time_elapsed > idle_seconds: + if time_elapsed is None or time_elapsed > idle_seconds: await self.move_to_dormant(channel) else: data = ChannelTimeout(channel, idle_seconds - time_elapsed) -- cgit v1.2.3 From 9f871a9d384ba2754bae047d62fb8a1bfd7e2141 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 15:19:39 -0800 Subject: HelpChannels: implement create_dormant Create and return a new channel in the Dormant category or return None if no names remain. The overwrites get synced with the category if none are explicitly specified for the channel. --- bot/cogs/help_channels.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index a848a3029..4a34bd37d 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -88,8 +88,22 @@ class HelpChannels(Scheduler, commands.Cog): return queue - async def create_dormant(self) -> discord.TextChannel: - """Create and return a new channel in the Dormant category.""" + async def create_dormant(self) -> t.Optional[discord.TextChannel]: + """ + Create and return a new channel in the Dormant category. + + The new channel will sync its permission overwrites with the category. + + Return None if no more channel names are available. + """ + name = constants.HelpChannels.name_prefix + + try: + name += self.name_queue.popleft() + except IndexError: + return None + + return await self.dormant_category.create_text_channel(name) def create_name_queue(self) -> deque: """Return a queue of element names to use for creating new channels.""" -- cgit v1.2.3 From 021fcbb20816f2031c9b190fb0c91d3e9b709b59 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 15:29:21 -0800 Subject: HelpChannels: implement get_available_candidate Return a dormant channel to turn into an available channel, waiting indefinitely until one becomes available in the queue. --- bot/cogs/help_channels.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 4a34bd37d..99815d4e5 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -117,7 +117,21 @@ class HelpChannels(Scheduler, commands.Cog): """Make the current in-use help channel dormant.""" async def get_available_candidate(self) -> discord.TextChannel: - """Return a dormant channel to turn into an available channel.""" + """ + Return a dormant channel to turn into an available channel. + + If no channel is available, wait indefinitely until one becomes available. + """ + try: + channel = self.channel_queue.get_nowait() + except asyncio.QueueEmpty: + channel = await self.create_dormant() + + if not channel: + # Wait for a channel to become available. + channel = await self.channel_queue.get() + + return channel @staticmethod def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: -- cgit v1.2.3 From 24bdb303547f7d03eaa7ed8cd7720e5cc0c91e8b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 15:57:28 -0800 Subject: HelpChannels: implement move_to_available Moves a channel to the Available category. Permissions will be synced with the new category. * Add stubs for channel topic constants --- bot/cogs/help_channels.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 99815d4e5..5e27757f7 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -16,6 +16,10 @@ from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) +# TODO: write the channel topics +AVAILABLE_TOPIC = "" +IN_USE_TOPIC = "" +DORMANT_TOPIC = "" ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" AVAILABLE_MSG = f""" @@ -216,6 +220,16 @@ class HelpChannels(Scheduler, commands.Cog): async def move_to_available(self) -> None: """Make a channel available.""" + channel = await self.get_available_candidate() + embed = discord.Embed(description=AVAILABLE_MSG) + + # TODO: edit or delete the dormant message + await channel.send(embed=embed) + await channel.edit( + category=self.available_category, + sync_permissions=True, + topic=AVAILABLE_TOPIC, + ) async def move_to_dormant(self, channel: discord.TextChannel) -> None: """Make the `channel` dormant.""" -- cgit v1.2.3 From 0c38d732da774f75c6c786b2fae5daaea6547b82 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 15:58:50 -0800 Subject: HelpChannels: implement move_to_dormant Moves a channel to the Dormant category. Permissions will be synced with the new category. --- bot/cogs/help_channels.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 5e27757f7..9ef7fc72c 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -233,6 +233,14 @@ class HelpChannels(Scheduler, commands.Cog): async def move_to_dormant(self, channel: discord.TextChannel) -> None: """Make the `channel` dormant.""" + await channel.edit( + category=self.dormant_category, + sync_permissions=True, + topic=DORMANT_TOPIC, + ) + + embed = discord.Embed(description=DORMANT_MSG) + await channel.send(embed=embed) @commands.Cog.listener() async def on_message(self, message: discord.Message) -> None: -- cgit v1.2.3 From 86bb25814d664442e4f4643d934c182b6f77107e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 16:35:57 -0800 Subject: HelpChannels: implement the !dormant command Basically a wrapper around move_to_dormant which ensures the current channel is in use. If it's not in-use, from the invoker's perspective, the command silently fails (it does at least log). InChannelCheckFailure was considered but it seemed like it'd be too spammy, especially if there'd be a long list of allowed channels. --- bot/cogs/help_channels.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 9ef7fc72c..b4121c7fd 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -12,6 +12,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot +from bot.decorators import with_role from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) @@ -117,8 +118,14 @@ class HelpChannels(Scheduler, commands.Cog): return deque(available_names) @commands.command(name="dormant") - async def dormant_command(self) -> None: + @with_role(*constants.HelpChannels.cmd_whitelist) + async def dormant_command(self, ctx: commands.Context) -> None: """Make the current in-use help channel dormant.""" + in_use = self.get_category_channels(self.in_use_category) + if ctx.channel in in_use: + await self.move_to_dormant(ctx.channel) + else: + log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") async def get_available_candidate(self) -> discord.TextChannel: """ -- cgit v1.2.3 From 7de241bd6ca7d156e3014611596d6bcb969f9c96 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 16:48:57 -0800 Subject: HelpChannels: add a function to make channels in-use It handles moving the channel to the category and scheduling it to be made dormant. --- bot/cogs/help_channels.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index b4121c7fd..806020873 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -249,6 +249,19 @@ class HelpChannels(Scheduler, commands.Cog): embed = discord.Embed(description=DORMANT_MSG) await channel.send(embed=embed) + async def move_to_in_use(self, channel: discord.TextChannel) -> None: + """Make a channel in-use and schedule it to be made dormant.""" + # Move the channel to the In Use category. + await channel.edit( + category=self.in_use_category, + sync_permissions=True, + topic=IN_USE_TOPIC, + ) + + # Schedule the channel to be moved to the Dormant category. + data = ChannelTimeout(channel, constants.HelpChannels.idle_minutes * 60) + self.schedule_task(self.bot.loop, channel.id, data) + @commands.Cog.listener() async def on_message(self, message: discord.Message) -> None: """Move an available channel to the In Use category and replace it with a dormant one.""" -- cgit v1.2.3 From 0595b550111cf684721f85a0e340880c9f15288a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 16:49:45 -0800 Subject: HelpChannels: implement the on_message listener It handles making channels in-use and replacing them with new available channels. --- bot/cogs/help_channels.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 806020873..6b77f9955 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -265,6 +265,15 @@ class HelpChannels(Scheduler, commands.Cog): @commands.Cog.listener() async def on_message(self, message: discord.Message) -> None: """Move an available channel to the In Use category and replace it with a dormant one.""" + available_channels = self.get_category_channels(self.available_category) + if message.channel not in available_channels: + return # Ignore messages outside the Available category. + + await self.move_to_in_use(message.channel) + + # Move a dormant channel to the Available category to fill in the gap. + # This is done last because it may wait indefinitely for a channel to be put in the queue. + await self.move_to_available() async def try_get_channel(self, channel_id: int) -> discord.abc.GuildChannel: """Attempt to get or fetch a channel and return it.""" -- cgit v1.2.3 From f3b54c2f35d2ab11e6ac88c94b1729fb2b86b781 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 16:53:22 -0800 Subject: HelpChannels: cancel scheduled tasks when the cog unloads * Make cog_unload a regular method instead of a coroutine --- 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 6b77f9955..f493e5918 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -74,10 +74,13 @@ class HelpChannels(Scheduler, commands.Cog): self.ready = asyncio.Event() self.init_task = asyncio.create_task(self.init_cog()) - async def cog_unload(self) -> None: - """Cancel the init task if the cog unloads.""" + def cog_unload(self) -> None: + """Cancel the init task and scheduled tasks when the cog unloads.""" self.init_task.cancel() + for task in self.scheduled_tasks.values(): + task.cancel() + def create_channel_queue(self) -> asyncio.Queue: """ Return a queue of dormant channels to use for getting the next available channel. -- cgit v1.2.3 From c1e485b11dee6f275a4c499e8f9be6cdde9e5e7a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 17:01:34 -0800 Subject: HelpChannels: cancel an existing task before scheduling a new one --- bot/cogs/help_channels.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index f493e5918..c547d9524 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -218,13 +218,21 @@ class HelpChannels(Scheduler, commands.Cog): self.ready.set() async def move_idle_channel(self, channel: discord.TextChannel) -> None: - """Make the `channel` dormant if idle or schedule the move if still active.""" + """ + Make the `channel` dormant if idle or schedule the move if still active. + + If a task to make the channel dormant already exists, it will first be cancelled. + """ idle_seconds = constants.HelpChannels.idle_minutes * 60 time_elapsed = await self.get_idle_time(channel) if time_elapsed is None or time_elapsed > idle_seconds: await self.move_to_dormant(channel) else: + # Cancel the existing task, if any. + if channel.id in self.scheduled_tasks: + self.cancel_task(channel.id) + data = ChannelTimeout(channel, idle_seconds - time_elapsed) self.schedule_task(self.bot.loop, channel.id, data) -- cgit v1.2.3 From 4c43e6e41be365c9134bacfb690fca55cb68f81f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 17:05:44 -0800 Subject: HelpChannels: implement _scheduled_task Make a channel dormant after specified timeout or reschedule if it's still active. --- bot/cogs/help_channels.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index c547d9524..12bed2e61 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -296,6 +296,11 @@ class HelpChannels(Scheduler, commands.Cog): async def _scheduled_task(self, data: ChannelTimeout) -> None: """Make a channel dormant after specified timeout or reschedule if it's still active.""" + await asyncio.sleep(data.timeout) + + # Use asyncio.shield to prevent move_idle_channel from cancelling itself. + # The parent task (_scheduled_task) will still get cancelled. + await asyncio.shield(self.move_idle_channel(data.channel)) def setup(bot: Bot) -> None: -- cgit v1.2.3 From d66c284034dec8352dc8a20ec1cb978c47f93d3d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 17:14:08 -0800 Subject: HelpChannels: wait for cog to be initialised before processing messages --- bot/cogs/help_channels.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 12bed2e61..43ce59cf1 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -276,6 +276,8 @@ class HelpChannels(Scheduler, commands.Cog): @commands.Cog.listener() async def on_message(self, message: discord.Message) -> None: """Move an available channel to the In Use category and replace it with a dormant one.""" + await self.ready.wait() + available_channels = self.get_category_channels(self.available_category) if message.channel not in available_channels: return # Ignore messages outside the Available category. -- cgit v1.2.3 From e1fb742253546b57611eb562fa0b1c839941a864 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 17:37:36 -0800 Subject: HelpChannels: use a lock to prevent a channel from being processed twice --- bot/cogs/help_channels.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 43ce59cf1..fd5632d09 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -72,6 +72,7 @@ class HelpChannels(Scheduler, commands.Cog): self.name_queue: deque = None self.ready = asyncio.Event() + self.on_message_lock = asyncio.Lock() self.init_task = asyncio.create_task(self.init_cog()) def cog_unload(self) -> None: @@ -278,14 +279,17 @@ class HelpChannels(Scheduler, commands.Cog): """Move an available channel to the In Use category and replace it with a dormant one.""" await self.ready.wait() - available_channels = self.get_category_channels(self.available_category) - if message.channel not in available_channels: - return # Ignore messages outside the Available category. + # Use a lock to prevent a channel from being processed twice. + with self.on_message_lock.acquire(): + available_channels = self.get_category_channels(self.available_category) + if message.channel not in available_channels: + return # Ignore messages outside the Available category. - await self.move_to_in_use(message.channel) + await self.move_to_in_use(message.channel) # Move a dormant channel to the Available category to fill in the gap. - # This is done last because it may wait indefinitely for a channel to be put in the queue. + # This is done last and outside the lock because it may wait indefinitely for a channel to + # be put in the queue. await self.move_to_available() async def try_get_channel(self, channel_id: int) -> discord.abc.GuildChannel: -- cgit v1.2.3 From 96ed02a565feabcc9415ae8909792323b08f9b08 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 18:26:09 -0800 Subject: HelpChannels: add logging --- bot/cogs/help_channels.py | 89 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 6 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index fd5632d09..82dce4ee7 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -77,8 +77,10 @@ class HelpChannels(Scheduler, commands.Cog): def cog_unload(self) -> None: """Cancel the init task and scheduled tasks when the cog unloads.""" + log.trace("Cog unload: cancelling the cog_init task") self.init_task.cancel() + log.trace("Cog unload: cancelling the scheduled tasks") for task in self.scheduled_tasks.values(): task.cancel() @@ -88,9 +90,12 @@ class HelpChannels(Scheduler, commands.Cog): The channels are added to the queue in a random order. """ + log.trace("Creating the channel queue.") + channels = list(self.get_category_channels(self.dormant_category)) random.shuffle(channels) + log.trace("Populating the channel queue with channels.") queue = asyncio.Queue() for channel in channels: queue.put_nowait(channel) @@ -105,26 +110,36 @@ class HelpChannels(Scheduler, commands.Cog): Return None if no more channel names are available. """ + log.trace("Getting a name for a new dormant channel.") name = constants.HelpChannels.name_prefix try: name += self.name_queue.popleft() except IndexError: + log.debug("No more names available for new dormant channels.") return None + log.debug(f"Creating a new dormant channel named {name}.") return await self.dormant_category.create_text_channel(name) def create_name_queue(self) -> deque: """Return a queue of element names to use for creating new channels.""" + log.trace("Creating the chemical element name queue.") + used_names = self.get_used_names() + + log.trace("Determining the available names.") available_names = (name for name in ELEMENTS if name not in used_names) + log.trace("Populating the name queue with names.") return deque(available_names) @commands.command(name="dormant") @with_role(*constants.HelpChannels.cmd_whitelist) async def dormant_command(self, ctx: commands.Context) -> None: """Make the current in-use help channel dormant.""" + log.trace("dormant command invoked; checking if the channel is in-use.") + in_use = self.get_category_channels(self.in_use_category) if ctx.channel in in_use: await self.move_to_dormant(ctx.channel) @@ -137,13 +152,16 @@ class HelpChannels(Scheduler, commands.Cog): If no channel is available, wait indefinitely until one becomes available. """ + log.trace("Getting an available channel candidate.") + try: channel = self.channel_queue.get_nowait() except asyncio.QueueEmpty: + log.info("No candidate channels in the queue; creating a new channel.") channel = await self.create_dormant() if not channel: - # Wait for a channel to become available. + log.info("Couldn't create a candidate channel; waiting to get one from the queue.") channel = await self.channel_queue.get() return channel @@ -151,6 +169,8 @@ class HelpChannels(Scheduler, commands.Cog): @staticmethod def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: """Yield the text channels of the `category` in an unsorted manner.""" + log.trace(f"Getting text channels in the category '{category.name}' ({category.id}).") + # This is faster than using category.channels because the latter sorts them. for channel in category.guild.channels: if channel.category_id == category.id and isinstance(channel, discord.TextChannel): @@ -158,6 +178,8 @@ class HelpChannels(Scheduler, commands.Cog): def get_used_names(self) -> t.Set[str]: """Return channels names which are already being used.""" + log.trace("Getting channel names which are already being used.") + start_index = len(constants.HelpChannels.name_prefix) names = set() @@ -166,6 +188,7 @@ class HelpChannels(Scheduler, commands.Cog): name = channel.name[start_index:] names.add(name) + log.trace(f"Got {len(names)} used names: {names}") return names @staticmethod @@ -175,23 +198,35 @@ class HelpChannels(Scheduler, commands.Cog): Return None if the channel has no messages. """ + log.trace(f"Getting the idle time for #{channel.name} ({channel.id}).") + try: msg = await channel.history(limit=1).next() # noqa: B305 except discord.NoMoreItems: + log.debug(f"No idle time available; #{channel.name} ({channel.id}) has no messages.") return None - return (datetime.utcnow() - msg.created_at).seconds + idle_time = (datetime.utcnow() - msg.created_at).seconds + + log.trace(f"#{channel.name} ({channel.id}) has been idle for {idle_time} seconds.") + return idle_time async def init_available(self) -> None: """Initialise the Available category with channels.""" + log.trace("Initialising the Available category with channels.") + channels = list(self.get_category_channels(self.available_category)) missing = constants.HelpChannels.max_available - len(channels) + log.trace(f"Moving {missing} missing channels to the Available category.") + for _ in range(missing): await self.move_to_available() async def init_categories(self) -> None: """Get the help category objects. Remove the cog if retrieval fails.""" + log.trace("Getting the CategoryChannel objects for the help categories.") + try: self.available_category = await self.try_get_channel( constants.Categories.help_available @@ -204,8 +239,10 @@ class HelpChannels(Scheduler, commands.Cog): async def init_cog(self) -> None: """Initialise the help channel system.""" + log.trace("Waiting for the guild to be available before initialisation.") await self.bot.wait_until_guild_available() + log.trace("Initialising the cog.") await self.init_categories() self.channel_queue = self.create_channel_queue() @@ -213,9 +250,11 @@ class HelpChannels(Scheduler, commands.Cog): await self.init_available() + log.trace("Moving or rescheduling in-use channels.") for channel in self.get_category_channels(self.in_use_category): await self.move_idle_channel(channel) + log.info("Cog is ready!") self.ready.set() async def move_idle_channel(self, channel: discord.TextChannel) -> None: @@ -224,10 +263,17 @@ class HelpChannels(Scheduler, commands.Cog): If a task to make the channel dormant already exists, it will first be cancelled. """ + log.trace(f"Handling in-use channel #{channel.name} ({channel.id}).") + idle_seconds = constants.HelpChannels.idle_minutes * 60 time_elapsed = await self.get_idle_time(channel) if time_elapsed is None or time_elapsed > idle_seconds: + log.info( + f"#{channel.name} ({channel.id}) is idle longer than {idle_seconds} seconds " + f"and will be made dormant." + ) + await self.move_to_dormant(channel) else: # Cancel the existing task, if any. @@ -235,15 +281,28 @@ class HelpChannels(Scheduler, commands.Cog): self.cancel_task(channel.id) data = ChannelTimeout(channel, idle_seconds - time_elapsed) + + log.info( + f"#{channel.name} ({channel.id}) is still active; " + f"scheduling it to be moved after {data.timeout} seconds." + ) + self.schedule_task(self.bot.loop, channel.id, data) async def move_to_available(self) -> None: """Make a channel available.""" + log.trace("Making a channel available.") + channel = await self.get_available_candidate() embed = discord.Embed(description=AVAILABLE_MSG) + log.info(f"Making #{channel.name} ({channel.id}) available.") + # TODO: edit or delete the dormant message + log.trace(f"Sending available message for #{channel.name} ({channel.id}).") await channel.send(embed=embed) + + log.trace(f"Moving #{channel.name} ({channel.id}) to the Available category.") await channel.edit( category=self.available_category, sync_permissions=True, @@ -252,40 +311,53 @@ class HelpChannels(Scheduler, commands.Cog): async def move_to_dormant(self, channel: discord.TextChannel) -> None: """Make the `channel` dormant.""" + log.info(f"Making #{channel.name} ({channel.id}) dormant.") + + log.trace(f"Moving #{channel.name} ({channel.id}) to the Dormant category.") await channel.edit( category=self.dormant_category, sync_permissions=True, topic=DORMANT_TOPIC, ) + log.trace(f"Sending dormant message for #{channel.name} ({channel.id}).") embed = discord.Embed(description=DORMANT_MSG) await channel.send(embed=embed) async def move_to_in_use(self, channel: discord.TextChannel) -> None: """Make a channel in-use and schedule it to be made dormant.""" - # Move the channel to the In Use category. + log.info(f"Making #{channel.name} ({channel.id}) in-use.") + + log.trace(f"Moving #{channel.name} ({channel.id}) to the In Use category.") await channel.edit( category=self.in_use_category, sync_permissions=True, topic=IN_USE_TOPIC, ) - # Schedule the channel to be moved to the Dormant category. - data = ChannelTimeout(channel, constants.HelpChannels.idle_minutes * 60) + timeout = constants.HelpChannels.idle_minutes * 60 + + log.trace(f"Scheduling #{channel.name} ({channel.id}) to become dormant in {timeout} sec.") + data = ChannelTimeout(channel, timeout) self.schedule_task(self.bot.loop, channel.id, data) @commands.Cog.listener() async def on_message(self, message: discord.Message) -> None: """Move an available channel to the In Use category and replace it with a dormant one.""" + log.trace("Waiting for the cog to be ready before processing messages.") await self.ready.wait() - # Use a lock to prevent a channel from being processed twice. + log.trace("Acquiring lock to prevent a channel from being processed twice...") with self.on_message_lock.acquire(): + log.trace("on_message lock acquired.") + log.trace("Checking if the message was sent in an available channel.") + available_channels = self.get_category_channels(self.available_category) if message.channel not in available_channels: return # Ignore messages outside the Available category. await self.move_to_in_use(message.channel) + log.trace("Releasing on_message lock.") # Move a dormant channel to the Available category to fill in the gap. # This is done last and outside the lock because it may wait indefinitely for a channel to @@ -294,14 +366,19 @@ class HelpChannels(Scheduler, commands.Cog): async def try_get_channel(self, channel_id: int) -> discord.abc.GuildChannel: """Attempt to get or fetch a channel and return it.""" + log.trace(f"Getting the channel {channel_id}.") + channel = self.bot.get_channel(channel_id) if not channel: + log.debug(f"Channel {channel_id} is not in cache; fetching from API.") channel = await self.bot.fetch_channel(channel_id) + log.trace(f"Channel #{channel.name} ({channel_id}) retrieved.") return channel async def _scheduled_task(self, data: ChannelTimeout) -> None: """Make a channel dormant after specified timeout or reschedule if it's still active.""" + log.trace(f"Waiting {data.timeout} before making #{data.channel.name} dormant.") await asyncio.sleep(data.timeout) # Use asyncio.shield to prevent move_idle_channel from cancelling itself. -- cgit v1.2.3 From 886692e3782a330ce0f6aab3b0a9612b65256736 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 18:27:33 -0800 Subject: Bot: load the help channels extension --- bot/__main__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/__main__.py b/bot/__main__.py index 3df477a6d..7ca6eabce 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -50,6 +50,7 @@ bot.load_extension("bot.cogs.defcon") bot.load_extension("bot.cogs.eval") bot.load_extension("bot.cogs.duck_pond") bot.load_extension("bot.cogs.free") +bot.load_extension("bot.cogs.help_channels") bot.load_extension("bot.cogs.information") bot.load_extension("bot.cogs.jams") bot.load_extension("bot.cogs.moderation") -- cgit v1.2.3 From 8e4b14052a887489b93366f6066f0636657c2570 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 18:28:23 -0800 Subject: Remove the free extension Obsolete due to the new help channel system. --- bot/__main__.py | 1 - bot/cogs/free.py | 103 ------------------------------------------------------- 2 files changed, 104 deletions(-) delete mode 100644 bot/cogs/free.py diff --git a/bot/__main__.py b/bot/__main__.py index 7ca6eabce..30a7dee41 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -49,7 +49,6 @@ bot.load_extension("bot.cogs.alias") bot.load_extension("bot.cogs.defcon") bot.load_extension("bot.cogs.eval") bot.load_extension("bot.cogs.duck_pond") -bot.load_extension("bot.cogs.free") bot.load_extension("bot.cogs.help_channels") bot.load_extension("bot.cogs.information") bot.load_extension("bot.cogs.jams") diff --git a/bot/cogs/free.py b/bot/cogs/free.py deleted file mode 100644 index 99516fade..000000000 --- a/bot/cogs/free.py +++ /dev/null @@ -1,103 +0,0 @@ -import logging -from datetime import datetime -from operator import itemgetter - -from discord import Colour, Embed, Member, utils -from discord.ext.commands import Cog, Context, command - -from bot.bot import Bot -from bot.constants import Categories, Channels, Free, STAFF_ROLES -from bot.decorators import redirect_output - -log = logging.getLogger(__name__) - -TIMEOUT = Free.activity_timeout -RATE = Free.cooldown_rate -PER = Free.cooldown_per - - -class Free(Cog): - """Tries to figure out which help channels are free.""" - - PYTHON_HELP_ID = Categories.help_in_use - - @command(name="free", aliases=('f',)) - @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES) - async def free(self, ctx: Context, user: Member = None, seek: int = 2) -> None: - """ - Lists free help channels by likeliness of availability. - - seek is used only when this command is invoked in a help channel. - You cannot override seek without mentioning a user first. - - When seek is 2, we are avoiding considering the last active message - in a channel to be the one that invoked this command. - - When seek is 3 or more, a user has been mentioned on the assumption - that they asked if the channel is free or they asked their question - in an active channel, and we want the message before that happened. - """ - free_channels = [] - python_help = utils.get(ctx.guild.categories, id=self.PYTHON_HELP_ID) - - if user is not None and seek == 2: - seek = 3 - elif not 0 < seek < 10: - seek = 3 - - # Iterate through all the help channels - # to check latest activity - for channel in python_help.channels: - # Seek further back in the help channel - # the command was invoked in - if channel.id == ctx.channel.id: - messages = await channel.history(limit=seek).flatten() - msg = messages[seek - 1] - # Otherwise get last message - else: - msg = await channel.history(limit=1).next() # noqa: B305 - - inactive = (datetime.utcnow() - msg.created_at).seconds - if inactive > TIMEOUT: - free_channels.append((inactive, channel)) - - embed = Embed() - embed.colour = Colour.blurple() - embed.title = "**Looking for a free help channel?**" - - if user is not None: - embed.description = f"**Hey {user.mention}!**\n\n" - else: - embed.description = "" - - # Display all potentially inactive channels - # in descending order of inactivity - if free_channels: - # Sort channels in descending order by seconds - # Get position in list, inactivity, and channel object - # For each channel, add to embed.description - sorted_channels = sorted(free_channels, key=itemgetter(0), reverse=True) - - for (inactive, channel) in sorted_channels[:3]: - minutes, seconds = divmod(inactive, 60) - if minutes > 59: - hours, minutes = divmod(minutes, 60) - embed.description += f"{channel.mention} **{hours}h {minutes}m {seconds}s** inactive\n" - else: - embed.description += f"{channel.mention} **{minutes}m {seconds}s** inactive\n" - - embed.set_footer(text="Please confirm these channels are free before posting") - else: - embed.description = ( - "Doesn't look like any channels are available right now. " - "You're welcome to check for yourself to be sure. " - "If all channels are truly busy, please be patient " - "as one will likely be available soon." - ) - - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the Free cog.""" - bot.add_cog(Free()) -- cgit v1.2.3 From ca4eb6bcd82a2bfaf620aaab0a7acee607051dc3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 18:33:55 -0800 Subject: HelpChannels: fix creation of the init_cog task The task has to be created on a specific loop because when the cog is instantiated, the event loop is not yet running. --- 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 82dce4ee7..391d400b1 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -73,7 +73,7 @@ class HelpChannels(Scheduler, commands.Cog): self.ready = asyncio.Event() self.on_message_lock = asyncio.Lock() - self.init_task = asyncio.create_task(self.init_cog()) + self.init_task = self.bot.loop.create_task(self.init_cog()) def cog_unload(self) -> None: """Cancel the init task and scheduled tasks when the cog unloads.""" -- cgit v1.2.3 From f5900f0f885f2e0c71a4accf8f680da09746070a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 18:41:47 -0800 Subject: Resources: make all element names lower cased --- bot/resources/elements.json | 236 ++++++++++++++++++++++---------------------- 1 file changed, 118 insertions(+), 118 deletions(-) diff --git a/bot/resources/elements.json b/bot/resources/elements.json index 61be9105f..2dc9b6fd6 100644 --- a/bot/resources/elements.json +++ b/bot/resources/elements.json @@ -1,120 +1,120 @@ [ - "Hydrogen", - "Helium", - "Lithium", - "Beryllium", - "Boron", - "Carbon", - "Nitrogen", - "Oxygen", - "Fluorine", - "Neon", - "Sodium", - "Magnesium", - "Aluminium", - "Silicon", - "Phosphorus", - "Sulfur", - "Chlorine", - "Argon", - "Potassium", - "Calcium", - "Scandium", - "Titanium", - "Vanadium", - "Chromium", - "Manganese", - "Iron", - "Cobalt", - "Nickel", - "Copper", - "Zinc", - "Gallium", - "Germanium", - "Arsenic", - "Selenium", - "Bromine", - "Krypton", - "Rubidium", - "Strontium", - "Yttrium", - "Zirconium", - "Niobium", - "Molybdenum", - "Technetium", - "Ruthenium", - "Rhodium", - "Palladium", - "Silver", - "Cadmium", - "Indium", - "Tin", - "Antimony", - "Tellurium", - "Iodine", - "Xenon", - "Caesium", - "Barium", - "Lanthanum", - "Cerium", - "Praseodymium", - "Neodymium", - "Promethium", - "Samarium", - "Europium", - "Gadolinium", - "Terbium", - "Dysprosium", - "Holmium", - "Erbium", - "Thulium", - "Ytterbium", - "Lutetium", - "Hafnium", - "Tantalum", - "Tungsten", - "Rhenium", - "Osmium", - "Iridium", - "Platinum", - "Gold", - "Mercury", - "Thallium", - "Lead", - "Bismuth", - "Polonium", - "Astatine", - "Radon", - "Francium", - "Radium", - "Actinium", - "Thorium", - "Protactinium", - "Uranium", - "Neptunium", - "Plutonium", - "Americium", - "Curium", - "Berkelium", - "Californium", - "Einsteinium", - "Fermium", - "Mendelevium", - "Nobelium", - "Lawrencium", - "Rutherfordium", - "Dubnium", - "Seaborgium", - "Bohrium", - "Hassium", - "Meitnerium", - "Darmstadtium", - "Roentgenium", - "Copernicium", - "Nihonium", - "Flerovium", - "Moscovium", - "Livermorium", - "Tennessine", - "Oganesson" + "hydrogen", + "helium", + "lithium", + "beryllium", + "boron", + "carbon", + "nitrogen", + "oxygen", + "fluorine", + "neon", + "sodium", + "magnesium", + "aluminium", + "silicon", + "phosphorus", + "sulfur", + "chlorine", + "argon", + "potassium", + "calcium", + "scandium", + "titanium", + "vanadium", + "chromium", + "manganese", + "iron", + "cobalt", + "nickel", + "copper", + "zinc", + "gallium", + "germanium", + "arsenic", + "selenium", + "bromine", + "krypton", + "rubidium", + "strontium", + "yttrium", + "zirconium", + "niobium", + "molybdenum", + "technetium", + "ruthenium", + "rhodium", + "palladium", + "silver", + "cadmium", + "indium", + "tin", + "antimony", + "tellurium", + "iodine", + "xenon", + "caesium", + "barium", + "lanthanum", + "cerium", + "praseodymium", + "neodymium", + "promethium", + "samarium", + "europium", + "gadolinium", + "terbium", + "dysprosium", + "holmium", + "erbium", + "thulium", + "ytterbium", + "lutetium", + "hafnium", + "tantalum", + "tungsten", + "rhenium", + "osmium", + "iridium", + "platinum", + "gold", + "mercury", + "thallium", + "lead", + "bismuth", + "polonium", + "astatine", + "radon", + "francium", + "radium", + "actinium", + "thorium", + "protactinium", + "uranium", + "neptunium", + "plutonium", + "americium", + "curium", + "berkelium", + "californium", + "einsteinium", + "fermium", + "mendelevium", + "nobelium", + "lawrencium", + "rutherfordium", + "dubnium", + "seaborgium", + "bohrium", + "hassium", + "meitnerium", + "darmstadtium", + "roentgenium", + "copernicium", + "nihonium", + "flerovium", + "moscovium", + "livermorium", + "tennessine", + "oganesson" ] -- cgit v1.2.3 From 8278d9780a94d44ee779748468dd80689287e91e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 18:46:14 -0800 Subject: HelpChannels: ignore messages sent by bots --- bot/cogs/help_channels.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 391d400b1..ce71d285a 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -344,6 +344,9 @@ class HelpChannels(Scheduler, commands.Cog): @commands.Cog.listener() async def on_message(self, message: discord.Message) -> None: """Move an available channel to the In Use category and replace it with a dormant one.""" + if message.author.bot: + return # Ignore messages sent by bots. + log.trace("Waiting for the cog to be ready before processing messages.") await self.ready.wait() -- cgit v1.2.3 From 2a8e4df7833f876864f4548fe555348a23371c10 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 18:46:25 -0800 Subject: HelpChannels: fix acquisition of the on_message lock * Use async_with * Don't call acquire() --- 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 ce71d285a..c5c542b3e 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -351,7 +351,7 @@ class HelpChannels(Scheduler, commands.Cog): await self.ready.wait() log.trace("Acquiring lock to prevent a channel from being processed twice...") - with self.on_message_lock.acquire(): + async with self.on_message_lock: log.trace("on_message lock acquired.") log.trace("Checking if the message was sent in an available channel.") -- cgit v1.2.3 From 3926497337e3e65eaa3711f963a447ab32faa811 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 19:22:03 -0800 Subject: HelpChannels: add missing units of time in messages --- bot/cogs/help_channels.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index c5c542b3e..7c9bf5e27 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -26,8 +26,8 @@ ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" AVAILABLE_MSG = f""" This help channel is now **available**, which means that you can claim it by simply typing your \ question into it. Once claimed, the channel will move into the **Help: In Use** category, and will \ -be yours until it has been inactive for {constants.HelpChannels.idle_minutes}. When that happens, \ -it will be set to **dormant** and moved into the **Help: Dormant** category. +be yours until it has been inactive for {constants.HelpChannels.idle_minutes} minutes. When that \ +happens, it will be set to **dormant** and moved into the **Help: Dormant** category. Try to write the best question you can by providing a detailed description and telling us what \ you've tried already. For more information on asking a good question, \ @@ -381,7 +381,7 @@ class HelpChannels(Scheduler, commands.Cog): async def _scheduled_task(self, data: ChannelTimeout) -> None: """Make a channel dormant after specified timeout or reschedule if it's still active.""" - log.trace(f"Waiting {data.timeout} before making #{data.channel.name} dormant.") + log.trace(f"Waiting {data.timeout} seconds before making #{data.channel.name} dormant.") await asyncio.sleep(data.timeout) # Use asyncio.shield to prevent move_idle_channel from cancelling itself. -- cgit v1.2.3 From 9af7dbd0f4691918c64a28d5c3d937e81d950289 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 19:22:50 -0800 Subject: HelpChannels: put channels in the queue when they go dormant --- bot/cogs/help_channels.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 7c9bf5e27..86cc5045d 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -324,6 +324,9 @@ class HelpChannels(Scheduler, commands.Cog): embed = discord.Embed(description=DORMANT_MSG) await channel.send(embed=embed) + log.trace(f"Pushing #{channel.name} ({channel.id}) into the channel queue.") + self.channel_queue.put_nowait(channel) + async def move_to_in_use(self, channel: discord.TextChannel) -> None: """Make a channel in-use and schedule it to be made dormant.""" log.info(f"Making #{channel.name} ({channel.id}) in-use.") -- cgit v1.2.3 From 342e5bd532417c48c0120ac4481482f384262b54 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 19:34:56 -0800 Subject: HelpChannels: add a function to get the last message in a channel --- bot/cogs/help_channels.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 86cc5045d..bda6ed7bd 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -191,8 +191,8 @@ class HelpChannels(Scheduler, commands.Cog): log.trace(f"Got {len(names)} used names: {names}") return names - @staticmethod - async def get_idle_time(channel: discord.TextChannel) -> t.Optional[int]: + @classmethod + async def get_idle_time(cls, channel: discord.TextChannel) -> t.Optional[int]: """ Return the time elapsed, in seconds, since the last message sent in the `channel`. @@ -200,9 +200,8 @@ class HelpChannels(Scheduler, commands.Cog): """ log.trace(f"Getting the idle time for #{channel.name} ({channel.id}).") - try: - msg = await channel.history(limit=1).next() # noqa: B305 - except discord.NoMoreItems: + msg = await cls.get_last_message(channel) + if not msg: log.debug(f"No idle time available; #{channel.name} ({channel.id}) has no messages.") return None @@ -211,6 +210,17 @@ class HelpChannels(Scheduler, commands.Cog): log.trace(f"#{channel.name} ({channel.id}) has been idle for {idle_time} seconds.") return idle_time + @staticmethod + async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.Message]: + """Return the last message sent in the channel or None if no messages exist.""" + log.trace(f"Getting the last message in #{channel.name} ({channel.id}).") + + try: + return await channel.history(limit=1).next() # noqa: B305 + except discord.NoMoreItems: + log.debug(f"No last message available; #{channel.name} ({channel.id}) has no messages.") + return None + async def init_available(self) -> None: """Initialise the Available category with channels.""" log.trace("Initialising the Available category with channels.") -- cgit v1.2.3 From f605da9b63e8f076296fb75bd5055cc333e46a84 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 19:41:31 -0800 Subject: HelpChannels: add a function to send or edit the available message Edits the dormant message or sends a new message if the dormant one cannot be found. --- bot/cogs/help_channels.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index bda6ed7bd..6c4c6c50e 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -304,13 +304,9 @@ class HelpChannels(Scheduler, commands.Cog): log.trace("Making a channel available.") channel = await self.get_available_candidate() - embed = discord.Embed(description=AVAILABLE_MSG) - log.info(f"Making #{channel.name} ({channel.id}) available.") - # TODO: edit or delete the dormant message - log.trace(f"Sending available message for #{channel.name} ({channel.id}).") - await channel.send(embed=embed) + await self.send_available_message(channel) log.trace(f"Moving #{channel.name} ({channel.id}) to the Available category.") await channel.edit( @@ -380,6 +376,21 @@ class HelpChannels(Scheduler, commands.Cog): # be put in the queue. await self.move_to_available() + async def send_available_message(self, channel: discord.TextChannel) -> None: + """Send the available message by editing a dormant message or sending a new message.""" + channel_info = f"#{channel.name} ({channel.id})" + log.trace(f"Sending available message in {channel_info}.") + + embed = discord.Embed(description=AVAILABLE_MSG) + + msg = await self.get_last_message(channel) + if msg: + log.trace(f"Found dormant message {msg.id} in {channel_info}; editing it.") + await msg.edit(embed=embed) + else: + log.trace(f"Dormant message not found in {channel_info}; sending a new message.") + await channel.send(embed=embed) + async def try_get_channel(self, channel_id: int) -> discord.abc.GuildChannel: """Attempt to get or fetch a channel and return it.""" log.trace(f"Getting the channel {channel_id}.") -- cgit v1.2.3 From b88ddd79267fd1a9c4406b81a729e04f514cbcd6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 19:45:14 -0800 Subject: HelpChannels: compare contents to confirm message is a dormant message * Add a new function to check if a message is a dormant message --- bot/cogs/help_channels.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 6c4c6c50e..010acfb34 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -267,6 +267,15 @@ class HelpChannels(Scheduler, commands.Cog): log.info("Cog is ready!") self.ready.set() + @staticmethod + def is_dormant_message(message: t.Optional[discord.Message]) -> bool: + """Return True if the contents of the `message` match `DORMANT_MSG`.""" + if not message or not message.embeds: + return False + + embed = message.embeds[0] + return embed.description.strip() == DORMANT_MSG.strip() + async def move_idle_channel(self, channel: discord.TextChannel) -> None: """ Make the `channel` dormant if idle or schedule the move if still active. @@ -384,7 +393,7 @@ class HelpChannels(Scheduler, commands.Cog): embed = discord.Embed(description=AVAILABLE_MSG) msg = await self.get_last_message(channel) - if msg: + if self.is_dormant_message(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 ffd3bce5e5af8acd9d681da537fe27ec94201818 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 20:14:02 -0800 Subject: HelpChannels: use >= instead of > to determine if timed out --- 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 010acfb34..231c34d7b 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -287,7 +287,7 @@ class HelpChannels(Scheduler, commands.Cog): idle_seconds = constants.HelpChannels.idle_minutes * 60 time_elapsed = await self.get_idle_time(channel) - if time_elapsed is None or time_elapsed > idle_seconds: + if time_elapsed is None or time_elapsed >= idle_seconds: log.info( f"#{channel.name} ({channel.id}) is idle longer than {idle_seconds} seconds " f"and will be made dormant." -- cgit v1.2.3 From 1b01d2ea453d9db429704f1265b260d4c8f8fd02 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 20:16:55 -0800 Subject: HelpChannels: cancel the task in _scheduled_task --- bot/cogs/help_channels.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 231c34d7b..394efd3b5 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -421,6 +421,8 @@ class HelpChannels(Scheduler, commands.Cog): # The parent task (_scheduled_task) will still get cancelled. await asyncio.shield(self.move_idle_channel(data.channel)) + self.cancel_task(data.channel.id) + def setup(bot: Bot) -> None: """Load the HelpChannels cog.""" -- cgit v1.2.3 From efb57117032f07cc2a3f2c23c9db6534701728ce Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Feb 2020 20:30:17 -0800 Subject: HelpChannels: explain the system in the cog docstring --- bot/cogs/help_channels.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 394efd3b5..b85fac4f1 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -57,7 +57,36 @@ class ChannelTimeout(t.NamedTuple): class HelpChannels(Scheduler, commands.Cog): - """Manage the help channel system of the guild.""" + """ + Manage the help channel system of the guild. + + The system is based on a 3-category system: + + Available Category + + * Contains channels which are ready to be occupied by someone who needs help + * Will always contain 2 channels; refilled automatically from the pool of dormant channels + * Prioritise using the channels which have been dormant for the longest amount of time + * If there are no more dormant channels, the bot will automatically create a new one + * Configurable with `constants.HelpChannels.max_available` + * When a channel becomes available, the dormant embed will be edited to show `AVAILABLE_MSG` + + In Use Category + + * Contains all channels which are occupied by someone needing help + * Channel moves to dormant category after 45 minutes of being idle + * Configurable with `constants.HelpChannels.idle_minutes` + * Helpers+ command can prematurely mark a channel as dormant + * Configurable with `constants.HelpChannels.cmd_whitelist` + * When a channel becomes dormant, an embed with `DORMANT_MSG` will be sent + + Dormant Category + + * Contains channels which aren't in use + * Channels are used to refill the Available category + + Help channels are named after the chemical elements in `bot/resources/elements.json`. + """ def __init__(self, bot: Bot): super().__init__() -- cgit v1.2.3 From a58e0687845cf8d6aafb559f24b092bc1f4af047 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 27 Feb 2020 09:18:03 -0800 Subject: HelpChannels: limit channels to a total of 50 Discord has a hard limit of 50 channels per category. It was decided 50 is plenty for now so no work will be done to support more than 50. --- bot/cogs/help_channels.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index b85fac4f1..2710e981b 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -46,7 +46,9 @@ through [our guide for asking a good question]({ASKING_GUIDE_URL}). """ with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: - ELEMENTS = json.load(elements_file) + # Discord has a hard limit of 50 channels per category. + # Easiest way to prevent more channels from being created is to limit the names available. + ELEMENTS = json.load(elements_file)[:50] class ChannelTimeout(t.NamedTuple): -- cgit v1.2.3 From 2491a68938f027c7fb08afa14b4130bbdd1da753 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 27 Feb 2020 09:48:24 -0800 Subject: HelpChannels: use more specific type hints for queues --- bot/cogs/help_channels.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 2710e981b..67bd1ab35 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -99,8 +99,8 @@ class HelpChannels(Scheduler, commands.Cog): self.in_use_category: discord.CategoryChannel = None self.dormant_category: discord.CategoryChannel = None - self.channel_queue: asyncio.Queue = None - self.name_queue: deque = None + self.channel_queue: asyncio.Queue[discord.TextChannel] = None + self.name_queue: t.Deque[str] = None self.ready = asyncio.Event() self.on_message_lock = asyncio.Lock() -- cgit v1.2.3 From c7a0914e8b6a2fffe70b449899ced97fb2619a0c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 27 Feb 2020 19:59:44 -0800 Subject: Resources: map element names to alphabetic indices The indices will be used to sort the elements alphabetically in the dormant category. --- bot/resources/elements.json | 240 ++++++++++++++++++++++---------------------- 1 file changed, 120 insertions(+), 120 deletions(-) diff --git a/bot/resources/elements.json b/bot/resources/elements.json index 2dc9b6fd6..bc9047397 100644 --- a/bot/resources/elements.json +++ b/bot/resources/elements.json @@ -1,120 +1,120 @@ -[ - "hydrogen", - "helium", - "lithium", - "beryllium", - "boron", - "carbon", - "nitrogen", - "oxygen", - "fluorine", - "neon", - "sodium", - "magnesium", - "aluminium", - "silicon", - "phosphorus", - "sulfur", - "chlorine", - "argon", - "potassium", - "calcium", - "scandium", - "titanium", - "vanadium", - "chromium", - "manganese", - "iron", - "cobalt", - "nickel", - "copper", - "zinc", - "gallium", - "germanium", - "arsenic", - "selenium", - "bromine", - "krypton", - "rubidium", - "strontium", - "yttrium", - "zirconium", - "niobium", - "molybdenum", - "technetium", - "ruthenium", - "rhodium", - "palladium", - "silver", - "cadmium", - "indium", - "tin", - "antimony", - "tellurium", - "iodine", - "xenon", - "caesium", - "barium", - "lanthanum", - "cerium", - "praseodymium", - "neodymium", - "promethium", - "samarium", - "europium", - "gadolinium", - "terbium", - "dysprosium", - "holmium", - "erbium", - "thulium", - "ytterbium", - "lutetium", - "hafnium", - "tantalum", - "tungsten", - "rhenium", - "osmium", - "iridium", - "platinum", - "gold", - "mercury", - "thallium", - "lead", - "bismuth", - "polonium", - "astatine", - "radon", - "francium", - "radium", - "actinium", - "thorium", - "protactinium", - "uranium", - "neptunium", - "plutonium", - "americium", - "curium", - "berkelium", - "californium", - "einsteinium", - "fermium", - "mendelevium", - "nobelium", - "lawrencium", - "rutherfordium", - "dubnium", - "seaborgium", - "bohrium", - "hassium", - "meitnerium", - "darmstadtium", - "roentgenium", - "copernicium", - "nihonium", - "flerovium", - "moscovium", - "livermorium", - "tennessine", - "oganesson" -] +{ + "hydrogen": 44, + "helium": 42, + "lithium": 53, + "beryllium": 9, + "boron": 12, + "carbon": 18, + "nitrogen": 69, + "oxygen": 73, + "fluorine": 34, + "neon": 64, + "sodium": 97, + "magnesium": 56, + "aluminium": 1, + "silicon": 95, + "phosphorus": 75, + "sulfur": 99, + "chlorine": 20, + "argon": 4, + "potassium": 79, + "calcium": 16, + "scandium": 92, + "titanium": 109, + "vanadium": 112, + "chromium": 21, + "manganese": 57, + "iron": 48, + "cobalt": 22, + "nickel": 66, + "copper": 24, + "zinc": 116, + "gallium": 37, + "germanium": 38, + "arsenic": 5, + "selenium": 94, + "bromine": 13, + "krypton": 49, + "rubidium": 88, + "strontium": 98, + "yttrium": 115, + "zirconium": 117, + "niobium": 68, + "molybdenum": 61, + "technetium": 101, + "ruthenium": 89, + "rhodium": 86, + "palladium": 74, + "silver": 96, + "cadmium": 14, + "indium": 45, + "tin": 108, + "antimony": 3, + "tellurium": 102, + "iodine": 46, + "xenon": 113, + "caesium": 15, + "barium": 7, + "lanthanum": 50, + "cerium": 19, + "praseodymium": 80, + "neodymium": 63, + "promethium": 81, + "samarium": 91, + "europium": 31, + "gadolinium": 36, + "terbium": 104, + "dysprosium": 28, + "holmium": 43, + "erbium": 30, + "thulium": 107, + "ytterbium": 114, + "lutetium": 55, + "hafnium": 40, + "tantalum": 100, + "tungsten": 110, + "rhenium": 85, + "osmium": 72, + "iridium": 47, + "platinum": 76, + "gold": 39, + "mercury": 60, + "thallium": 105, + "lead": 52, + "bismuth": 10, + "polonium": 78, + "astatine": 6, + "radon": 84, + "francium": 35, + "radium": 83, + "actinium": 0, + "thorium": 106, + "protactinium": 82, + "uranium": 111, + "neptunium": 65, + "plutonium": 77, + "americium": 2, + "curium": 25, + "berkelium": 8, + "californium": 17, + "einsteinium": 29, + "fermium": 32, + "mendelevium": 59, + "nobelium": 70, + "lawrencium": 51, + "rutherfordium": 90, + "dubnium": 27, + "seaborgium": 93, + "bohrium": 11, + "hassium": 41, + "meitnerium": 58, + "darmstadtium": 26, + "roentgenium": 87, + "copernicium": 23, + "nihonium": 67, + "flerovium": 33, + "moscovium": 62, + "livermorium": 54, + "tennessine": 103, + "oganesson": 71 +} \ No newline at end of file -- cgit v1.2.3 From 1cd781f787c861790036f02f5c9c0ed3fa1d27cd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 28 Feb 2020 21:16:02 -0800 Subject: Constants: add constant for max total help channels Represents the total number of help channels across all 3 categories. --- bot/constants.py | 1 + config-default.yml | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 5b50050d6..2f484f0ad 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -540,6 +540,7 @@ class HelpChannels(metaclass=YAMLGetter): cmd_whitelist: List[int] idle_minutes: int max_available: int + max_total_channels: int name_prefix: str diff --git a/config-default.yml b/config-default.yml index c095aa30b..a24092235 100644 --- a/config-default.yml +++ b/config-default.yml @@ -525,6 +525,10 @@ help_channels: # Maximum number of channels to put in the available category max_available: 2 + # Maximum number of channels across all 3 categories + # Note Discord has a hard limit of 50 channels per category, so this shouldn't be > 50 + max_total_channels: 50 + # Prefix for help channel names name_prefix: 'help-' -- cgit v1.2.3 From 37ec93d8eaf255ff9c118469d8a86231c9427680 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 29 Feb 2020 07:51:24 -0800 Subject: HelpChannels: move reading of element names to a function Makes it easier to test. --- bot/cogs/help_channels.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 67bd1ab35..0c6c48914 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -45,11 +45,6 @@ question to maximize your chance of getting a good answer. If you're not sure ho through [our guide for asking a good question]({ASKING_GUIDE_URL}). """ -with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: - # Discord has a hard limit of 50 channels per category. - # Easiest way to prevent more channels from being created is to limit the names available. - ELEMENTS = json.load(elements_file)[:50] - class ChannelTimeout(t.NamedTuple): """Data for a task scheduled to make a channel dormant.""" @@ -102,6 +97,8 @@ class HelpChannels(Scheduler, commands.Cog): self.channel_queue: asyncio.Queue[discord.TextChannel] = None self.name_queue: t.Deque[str] = None + self.elements = self.get_names() + self.ready = asyncio.Event() self.on_message_lock = asyncio.Lock() self.init_task = self.bot.loop.create_task(self.init_cog()) @@ -160,7 +157,7 @@ class HelpChannels(Scheduler, commands.Cog): used_names = self.get_used_names() log.trace("Determining the available names.") - available_names = (name for name in ELEMENTS if name not in used_names) + available_names = (name for name in self.elements if name not in used_names) log.trace("Populating the name queue with names.") return deque(available_names) @@ -207,6 +204,14 @@ class HelpChannels(Scheduler, commands.Cog): if channel.category_id == category.id and isinstance(channel, discord.TextChannel): yield channel + @staticmethod + def get_names() -> t.List[str]: + """Return a list of element names.""" + with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: + # Discord has a hard limit of 50 channels per category. + # Easiest way to prevent more channels from being created is to limit available names. + return json.load(elements_file)[:50] + def get_used_names(self) -> t.Set[str]: """Return channels names which are already being used.""" log.trace("Getting channel names which are already being used.") -- cgit v1.2.3 From a3f4f3d19b6ba82b7bbb2e2bf01416c5fd1c0f31 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 29 Feb 2020 07:58:35 -0800 Subject: HelpChannels: return elements as a truncated dict of names --- bot/cogs/help_channels.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 0c6c48914..c8609f168 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -1,4 +1,5 @@ import asyncio +import itertools import json import logging import random @@ -205,12 +206,13 @@ class HelpChannels(Scheduler, commands.Cog): yield channel @staticmethod - def get_names() -> t.List[str]: - """Return a list of element names.""" + def get_names(count: int = constants.HelpChannels.max_total_channels) -> t.Dict[str, int]: + """Return a dict with the first `count` element names and their alphabetical indices.""" with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: - # Discord has a hard limit of 50 channels per category. - # Easiest way to prevent more channels from being created is to limit available names. - return json.load(elements_file)[:50] + all_names = json.load(elements_file) + + truncated_names = itertools.islice(all_names.items(), count) + return dict(truncated_names) def get_used_names(self) -> t.Set[str]: """Return channels names which are already being used.""" -- cgit v1.2.3 From 379f4093b1ea2e8f403046512486a0d962ab697d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 29 Feb 2020 08:04:01 -0800 Subject: HelpChannels: warn if too many help channels will be possible Discord only supports 50 channels per category. * Add a constant for the maximum number of channels per category * Add trace logging to `get_names` --- bot/cogs/help_channels.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index c8609f168..64443f81c 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -23,6 +23,7 @@ AVAILABLE_TOPIC = "" IN_USE_TOPIC = "" DORMANT_TOPIC = "" ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" +MAX_CHANNELS_PER_CATEGORY = 50 AVAILABLE_MSG = f""" This help channel is now **available**, which means that you can claim it by simply typing your \ @@ -208,6 +209,14 @@ class HelpChannels(Scheduler, commands.Cog): @staticmethod def get_names(count: int = constants.HelpChannels.max_total_channels) -> t.Dict[str, int]: """Return a dict with the first `count` element names and their alphabetical indices.""" + log.trace(f"Getting the first {count} element names from JSON.") + + if count > MAX_CHANNELS_PER_CATEGORY: + log.warning( + f"{count} is too many help channels to make available! " + f"Discord only supports at most {MAX_CHANNELS_PER_CATEGORY} channels per category." + ) + with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: all_names = json.load(elements_file) -- cgit v1.2.3 From 9597921a8a11e3e915e5fe49e7a1ace46a81b98f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 27 Feb 2020 20:16:30 -0800 Subject: HelpChannels: sort dormant channels alphabetically The channels are easier to find when sorted alphabetically. * Merge some trace and info logs --- bot/cogs/help_channels.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 64443f81c..0f700a9ba 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -373,13 +373,16 @@ class HelpChannels(Scheduler, commands.Cog): async def move_to_dormant(self, channel: discord.TextChannel) -> None: """Make the `channel` dormant.""" - log.info(f"Making #{channel.name} ({channel.id}) dormant.") + log.info(f"Moving #{channel.name} ({channel.id}) to the Dormant category.") + + start_index = len(constants.HelpChannels.name_prefix) + element = channel.name[start_index:] - log.trace(f"Moving #{channel.name} ({channel.id}) to the Dormant category.") await channel.edit( category=self.dormant_category, sync_permissions=True, topic=DORMANT_TOPIC, + position=self.elements[element], ) log.trace(f"Sending dormant message for #{channel.name} ({channel.id}).") @@ -391,9 +394,8 @@ class HelpChannels(Scheduler, commands.Cog): async def move_to_in_use(self, channel: discord.TextChannel) -> None: """Make a channel in-use and schedule it to be made dormant.""" - log.info(f"Making #{channel.name} ({channel.id}) in-use.") + log.info(f"Moving #{channel.name} ({channel.id}) to the In Use category.") - log.trace(f"Moving #{channel.name} ({channel.id}) to the In Use category.") await channel.edit( category=self.in_use_category, sync_permissions=True, -- cgit v1.2.3 From 05ea5a932c0e8941d22e64f3a616772876816018 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 27 Feb 2020 20:20:06 -0800 Subject: HelpChannels: add a warning if more than 50 channels exist Discord only supports 50 channels per category. The help system will eventually error out trying to move channels if more than 50 exist. --- bot/cogs/help_channels.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 0f700a9ba..4aad55cfe 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -235,6 +235,12 @@ class HelpChannels(Scheduler, commands.Cog): name = channel.name[start_index:] names.add(name) + if len(names) > MAX_CHANNELS_PER_CATEGORY: + log.warning( + f"Too many help channels ({len(names)}) already exist! " + f"Discord only supports {MAX_CHANNELS_PER_CATEGORY} in a category." + ) + log.trace(f"Got {len(names)} used names: {names}") return names -- cgit v1.2.3 From a0eb4e1872426f4b6675f9e08204bf295dbaa09f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 27 Feb 2020 21:20:44 -0800 Subject: HelpChannels: add a function to get a channel's alphabetical position * Log a warning if a channel lacks the expected help channel prefix * Log the old and new channel positions --- bot/cogs/help_channels.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 4aad55cfe..bb2103c3a 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -196,6 +196,26 @@ class HelpChannels(Scheduler, commands.Cog): return channel + def get_alphabetical_position(self, channel: discord.TextChannel) -> t.Optional[int]: + """ + Return the position to move `channel` to so alphabetic order is maintained. + + If the channel does not have a valid name with a chemical element, return None. + """ + log.trace(f"Getting alphabetical position for #{channel.name} ({channel.id}).") + + prefix = constants.HelpChannels.name_prefix + element = channel.name[len(prefix):] + + try: + position = self.elements[element] + except KeyError: + log.warning(f"Channel #{channel.name} ({channel.id}) doesn't have the prefix {prefix}.") + position = None + + log.trace(f"Position of #{channel.name} ({channel.id}) in Dormant will be {position}.") + return position + @staticmethod def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: """Yield the text channels of the `category` in an unsorted manner.""" @@ -381,16 +401,15 @@ class HelpChannels(Scheduler, commands.Cog): """Make the `channel` dormant.""" log.info(f"Moving #{channel.name} ({channel.id}) to the Dormant category.") - start_index = len(constants.HelpChannels.name_prefix) - element = channel.name[start_index:] - await channel.edit( category=self.dormant_category, sync_permissions=True, topic=DORMANT_TOPIC, - position=self.elements[element], + position=self.get_alphabetical_position(channel), ) + log.trace(f"Position of #{channel.name} ({channel.id}) is actually {channel.position}.") + log.trace(f"Sending dormant message for #{channel.name} ({channel.id}).") embed = discord.Embed(description=DORMANT_MSG) await channel.send(embed=embed) -- cgit v1.2.3 From bcad1ad3a06df3a0d350839f2d861882f05dcd84 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 27 Feb 2020 22:13:43 -0800 Subject: HelpChannels: notify helpers if out of channels Send a message in the #helpers channel pinging the @helpers role to notify them of a lack of help channels. Can be toggled off in the config. --- bot/cogs/help_channels.py | 9 +++++++++ bot/constants.py | 1 + config-default.yml | 3 +++ 3 files changed, 13 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index bb2103c3a..f9fe9e6de 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -192,6 +192,15 @@ class HelpChannels(Scheduler, commands.Cog): if not channel: log.info("Couldn't create a candidate channel; waiting to get one from the queue.") + + if constants.HelpChannels.notify_helpers: + helpers_channel = self.bot.get_channel(constants.Channels.helpers) + await helpers_channel.send( + f"<@&{constants.Roles.helpers}> a help channel is in needed but none are " + f"available. Consider freeing up some in-use channels manually by using " + f"the `!dormant` command within the channels." + ) + channel = await self.channel_queue.get() return channel diff --git a/bot/constants.py b/bot/constants.py index 2f484f0ad..56ab7f84c 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -542,6 +542,7 @@ class HelpChannels(metaclass=YAMLGetter): max_available: int max_total_channels: int name_prefix: str + notify_helpers: bool class Mention(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index a24092235..0c4e226aa 100644 --- a/config-default.yml +++ b/config-default.yml @@ -532,6 +532,9 @@ help_channels: # Prefix for help channel names name_prefix: 'help-' + # Notify helpers if more channels are needed but none are available + notify_helpers: true + redirect_output: delete_invocation: true delete_delay: 15 -- cgit v1.2.3 From f07221b8135eb067490f51ee79832893cd4ba610 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 28 Feb 2020 07:28:19 -0800 Subject: HelpChannels: log previous position when getting alphabetical position --- bot/cogs/help_channels.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index f9fe9e6de..85015b5e9 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -222,7 +222,11 @@ class HelpChannels(Scheduler, commands.Cog): log.warning(f"Channel #{channel.name} ({channel.id}) doesn't have the prefix {prefix}.") position = None - log.trace(f"Position of #{channel.name} ({channel.id}) in Dormant will be {position}.") + log.trace( + f"Position of #{channel.name} ({channel.id}) in Dormant will be {position} " + f"(was {channel.position})." + ) + return position @staticmethod -- cgit v1.2.3 From f7e29ba78ec3f25965ae9fb70729cc3eb895e808 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 28 Feb 2020 07:43:40 -0800 Subject: HelpChannels: add a minimum interval between helper notifications * Add configurable constant for minimum interval * Move helper notifications to a separate function --- bot/cogs/help_channels.py | 37 ++++++++++++++++++++++++++++--------- bot/constants.py | 1 + config-default.yml | 3 +++ 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 85015b5e9..809551131 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -100,6 +100,7 @@ class HelpChannels(Scheduler, commands.Cog): self.name_queue: t.Deque[str] = None self.elements = self.get_names() + self.last_notification: t.Optional[datetime] = None self.ready = asyncio.Event() self.on_message_lock = asyncio.Lock() @@ -192,15 +193,7 @@ class HelpChannels(Scheduler, commands.Cog): if not channel: log.info("Couldn't create a candidate channel; waiting to get one from the queue.") - - if constants.HelpChannels.notify_helpers: - helpers_channel = self.bot.get_channel(constants.Channels.helpers) - await helpers_channel.send( - f"<@&{constants.Roles.helpers}> a help channel is in needed but none are " - f"available. Consider freeing up some in-use channels manually by using " - f"the `!dormant` command within the channels." - ) - + await self.notify_helpers() channel = await self.channel_queue.get() return channel @@ -446,6 +439,32 @@ class HelpChannels(Scheduler, commands.Cog): data = ChannelTimeout(channel, timeout) self.schedule_task(self.bot.loop, channel.id, data) + async def notify_helpers(self) -> None: + """ + Notify helpers in the #helpers channel about a lack of available help channels. + + The notification can be disabled with `constants.HelpChannels.notify_helpers`. The + minimum interval between notifications can be configured with + `constants.HelpChannels.notify_minutes`. + """ + if not constants.HelpChannels.notify_helpers: + return + + if self.last_notification: + elapsed = (datetime.utcnow() - self.last_notification).seconds + minimum_interval = constants.HelpChannels.notify_minutes * 60 + should_send = elapsed >= minimum_interval + else: + should_send = True + + if should_send: + helpers_channel = self.bot.get_channel(constants.Channels.helpers) + await helpers_channel.send( + f"<@&{constants.Roles.helpers}> a help channel is in needed but none are " + f"available. Consider freeing up some in-use channels manually by using " + f"the `!dormant` command within the channels." + ) + @commands.Cog.listener() async def on_message(self, message: discord.Message) -> None: """Move an available channel to the In Use category and replace it with a dormant one.""" diff --git a/bot/constants.py b/bot/constants.py index 56ab7f84c..00694e1d7 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -543,6 +543,7 @@ class HelpChannels(metaclass=YAMLGetter): max_total_channels: int name_prefix: str notify_helpers: bool + notify_minutes: int class Mention(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index 0c4e226aa..764cf5a3e 100644 --- a/config-default.yml +++ b/config-default.yml @@ -535,6 +535,9 @@ help_channels: # Notify helpers if more channels are needed but none are available notify_helpers: true + # Minimum interval between helper notifications + notify_minutes: 15 + redirect_output: delete_invocation: true delete_delay: 15 -- cgit v1.2.3 From f01400c2516add585b8dcc90417e0273b3f87cde Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 28 Feb 2020 07:45:02 -0800 Subject: HelpChannels: adjust the helper notification message --- bot/cogs/help_channels.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 809551131..f63370b24 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -460,9 +460,9 @@ class HelpChannels(Scheduler, commands.Cog): if should_send: helpers_channel = self.bot.get_channel(constants.Channels.helpers) await helpers_channel.send( - f"<@&{constants.Roles.helpers}> a help channel is in needed but none are " - f"available. Consider freeing up some in-use channels manually by using " - f"the `!dormant` command within the channels." + f"<@&{constants.Roles.helpers}> a new available help channel is needed but there " + f"are no more dormant ones. Consider freeing up some in-use channels manually by " + f"using the `!dormant` command within the channels." ) @commands.Cog.listener() -- cgit v1.2.3 From 52b89b4cc406d0f9346e28b5ceb9bc8712be59b8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 28 Feb 2020 08:01:00 -0800 Subject: Constants: rename HelpChannels.notify_helpers to notify --- bot/cogs/help_channels.py | 2 +- bot/constants.py | 2 +- config-default.yml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index f63370b24..dde52e1e7 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -447,7 +447,7 @@ class HelpChannels(Scheduler, commands.Cog): minimum interval between notifications can be configured with `constants.HelpChannels.notify_minutes`. """ - if not constants.HelpChannels.notify_helpers: + if not constants.HelpChannels.notify: return if self.last_notification: diff --git a/bot/constants.py b/bot/constants.py index 00694e1d7..32968cfaa 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -542,7 +542,7 @@ class HelpChannels(metaclass=YAMLGetter): max_available: int max_total_channels: int name_prefix: str - notify_helpers: bool + notify: bool notify_minutes: int diff --git a/config-default.yml b/config-default.yml index 764cf5a3e..27dfbdc64 100644 --- a/config-default.yml +++ b/config-default.yml @@ -532,8 +532,8 @@ help_channels: # Prefix for help channel names name_prefix: 'help-' - # Notify helpers if more channels are needed but none are available - notify_helpers: true + # Notify if more available channels are needed but there are no more dormant ones + notify: true # Minimum interval between helper notifications notify_minutes: 15 -- cgit v1.2.3 From 77392c9522eb2f69657d41537ba4e2109c35c315 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 28 Feb 2020 08:05:23 -0800 Subject: Constants: add a channel constant for help channel notifications --- bot/cogs/help_channels.py | 12 ++++++------ bot/constants.py | 1 + config-default.yml | 4 ++++ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index dde52e1e7..d50a21f0b 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -441,11 +441,11 @@ class HelpChannels(Scheduler, commands.Cog): async def notify_helpers(self) -> None: """ - Notify helpers in the #helpers channel about a lack of available help channels. + Notify helpers about a lack of available help channels. - The notification can be disabled with `constants.HelpChannels.notify_helpers`. The - minimum interval between notifications can be configured with - `constants.HelpChannels.notify_minutes`. + The notification can be disabled with `constants.HelpChannels.notify`. The minimum interval + between notifications can be configured with `constants.HelpChannels.notify_minutes`. The + channel for notifications can be configured with `constants.HelpChannels.notify_channel`. """ if not constants.HelpChannels.notify: return @@ -458,8 +458,8 @@ class HelpChannels(Scheduler, commands.Cog): should_send = True if should_send: - helpers_channel = self.bot.get_channel(constants.Channels.helpers) - await helpers_channel.send( + channel = self.bot.get_channel(constants.HelpChannels.notify_channel) + await channel.send( f"<@&{constants.Roles.helpers}> a new available help channel is needed but there " f"are no more dormant ones. Consider freeing up some in-use channels manually by " f"using the `!dormant` command within the channels." diff --git a/bot/constants.py b/bot/constants.py index 32968cfaa..7a0760eef 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -543,6 +543,7 @@ class HelpChannels(metaclass=YAMLGetter): max_total_channels: int name_prefix: str notify: bool + notify_channel: int notify_minutes: int diff --git a/config-default.yml b/config-default.yml index 27dfbdc64..b23271899 100644 --- a/config-default.yml +++ b/config-default.yml @@ -532,9 +532,13 @@ help_channels: # Prefix for help channel names name_prefix: 'help-' + # Notify if more available channels are needed but there are no more dormant ones notify: true + # Channel in which to send notifications + notify_channel: *HELPERS + # Minimum interval between helper notifications notify_minutes: 15 -- cgit v1.2.3 From 91fd12b0b61bfe731095d30450ac5a24f7ff948c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 28 Feb 2020 08:07:01 -0800 Subject: Constants: add a roles list constant for help channel notifications --- bot/constants.py | 1 + config-default.yml | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index 7a0760eef..042e48a8b 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -545,6 +545,7 @@ class HelpChannels(metaclass=YAMLGetter): notify: bool notify_channel: int notify_minutes: int + notify_roles: List[int] class Mention(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index b23271899..22c05853c 100644 --- a/config-default.yml +++ b/config-default.yml @@ -532,7 +532,6 @@ help_channels: # Prefix for help channel names name_prefix: 'help-' - # Notify if more available channels are needed but there are no more dormant ones notify: true @@ -542,6 +541,10 @@ help_channels: # Minimum interval between helper notifications notify_minutes: 15 + # Mention these roles in notifications + notify_roles: + - *HELPERS_ROLE + redirect_output: delete_invocation: true delete_delay: 15 -- cgit v1.2.3 From 8ffa5930214d53adc18554b982d6556815e5bd97 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 28 Feb 2020 08:28:42 -0800 Subject: HelpChannels: notify configured list of roles instead of helpers only * Rename function `notify_helpers` -> `notify` * Use bullet-point list for config options in the docstring --- bot/cogs/help_channels.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index d50a21f0b..28bbac790 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -193,7 +193,7 @@ class HelpChannels(Scheduler, commands.Cog): if not channel: log.info("Couldn't create a candidate channel; waiting to get one from the queue.") - await self.notify_helpers() + await self.notify() channel = await self.channel_queue.get() return channel @@ -439,13 +439,16 @@ class HelpChannels(Scheduler, commands.Cog): data = ChannelTimeout(channel, timeout) self.schedule_task(self.bot.loop, channel.id, data) - async def notify_helpers(self) -> None: + async def notify(self) -> None: """ - Notify helpers about a lack of available help channels. + Send a message notifying about a lack of available help channels. - The notification can be disabled with `constants.HelpChannels.notify`. The minimum interval - between notifications can be configured with `constants.HelpChannels.notify_minutes`. The - channel for notifications can be configured with `constants.HelpChannels.notify_channel`. + Configuration: + + * `HelpChannels.notify` - toggle notifications + * `HelpChannels.notify_channel` - destination channel for notifications + * `HelpChannels.notify_minutes` - minimum interval between notifications + * `HelpChannels.notify_roles` - roles mentioned in notifications """ if not constants.HelpChannels.notify: return @@ -459,8 +462,10 @@ class HelpChannels(Scheduler, commands.Cog): if should_send: channel = self.bot.get_channel(constants.HelpChannels.notify_channel) + mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_roles) + await channel.send( - f"<@&{constants.Roles.helpers}> a new available help channel is needed but there " + f"{mentions} A new available help channel is needed but there " f"are no more dormant ones. Consider freeing up some in-use channels manually by " f"using the `!dormant` command within the channels." ) -- cgit v1.2.3 From cc5753e48d5ed5c3f2b73f8a724ab34b446cb5ef Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 28 Feb 2020 08:33:15 -0800 Subject: HelpChannels: handle potential notification exceptions locally The notification feature is not critical for the functionality of the help channel system. Therefore, the exception should not be allowed to propagate and halt the system in some way. --- bot/cogs/help_channels.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 28bbac790..45509e1c7 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -460,7 +460,10 @@ class HelpChannels(Scheduler, commands.Cog): else: should_send = True - if should_send: + if not should_send: + return + + try: channel = self.bot.get_channel(constants.HelpChannels.notify_channel) mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_roles) @@ -469,6 +472,9 @@ class HelpChannels(Scheduler, commands.Cog): f"are no more dormant ones. Consider freeing up some in-use channels manually by " f"using the `!dormant` command within the channels." ) + except Exception: + # Handle it here cause this feature isn't critical for the functionality of the system. + log.exception("Failed to send notification about lack of dormant channels!") @commands.Cog.listener() async def on_message(self, message: discord.Message) -> None: -- cgit v1.2.3 From deef254e905bf55ebadf36e0e2a5e1616a796706 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 28 Feb 2020 11:04:51 -0800 Subject: Constants: remove old help channel constants --- bot/constants.py | 8 -------- config-default.yml | 10 ---------- 2 files changed, 18 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 042e48a8b..7f19f8a0e 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -376,14 +376,6 @@ class Channels(metaclass=YAMLGetter): dev_core: int dev_log: int esoteric: int - help_0: int - help_1: int - help_2: int - help_3: int - help_4: int - help_5: int - help_6: int - help_7: int helpers: int message_log: int meta: int diff --git a/config-default.yml b/config-default.yml index 22c05853c..b91df4580 100644 --- a/config-default.yml +++ b/config-default.yml @@ -140,16 +140,6 @@ guild: off_topic_1: 463035241142026251 off_topic_2: 463035268514185226 - # Python Help - help_0: 303906576991780866 - help_1: 303906556754395136 - help_2: 303906514266226689 - help_3: 439702951246692352 - help_4: 451312046647148554 - help_5: 454941769734422538 - help_6: 587375753306570782 - help_7: 587375768556797982 - # Special bot_commands: &BOT_CMD 267659945086812160 esoteric: 470884583684964352 -- cgit v1.2.3 From d1fe7eb011c9d124173045efc408f5b022a0c356 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 28 Feb 2020 11:06:53 -0800 Subject: BotCog: determine if a help channel by checking its category Initialising the dictionary with help channel IDs doesn't work anymore since help channels are now dynamic. --- bot/cogs/bot.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index f17135877..267892cc3 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -9,7 +9,7 @@ from discord.ext.commands import Cog, Context, command, group from bot.bot import Bot from bot.cogs.token_remover import TokenRemover -from bot.constants import Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs +from bot.constants import Categories, Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs from bot.decorators import with_role from bot.utils.messages import wait_for_deletion @@ -26,14 +26,6 @@ class BotCog(Cog, name="Bot"): # Stores allowed channels plus epoch time since last call. self.channel_cooldowns = { - Channels.help_0: 0, - Channels.help_1: 0, - Channels.help_2: 0, - Channels.help_3: 0, - Channels.help_4: 0, - Channels.help_5: 0, - Channels.help_6: 0, - Channels.help_7: 0, Channels.python_discussion: 0, } @@ -232,9 +224,14 @@ class BotCog(Cog, name="Bot"): If poorly formatted code is detected, send the user a helpful message explaining how to do properly formatted Python syntax highlighting codeblocks. """ + is_help_channel = ( + msg.channel.category + and msg.channel.category.id in (Categories.help_available, Categories.help_in_use) + ) parse_codeblock = ( ( - msg.channel.id in self.channel_cooldowns + is_help_channel + or msg.channel.id in self.channel_cooldowns or msg.channel.id in self.channel_whitelist ) and not msg.author.bot -- cgit v1.2.3 From 5a9661106186ab370b31aa2858210f121a944846 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 28 Feb 2020 11:15:38 -0800 Subject: HelpChannels: move newest in-use channel to the top This gives the newest questions the most visibility. --- bot/cogs/help_channels.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 45509e1c7..ab2f5b8c3 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -431,6 +431,7 @@ class HelpChannels(Scheduler, commands.Cog): category=self.in_use_category, sync_permissions=True, topic=IN_USE_TOPIC, + position=0, ) timeout = constants.HelpChannels.idle_minutes * 60 -- cgit v1.2.3 From e42f4a7d6487fa76cb41abad2dda2f7e2e5b7c4d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 28 Feb 2020 11:29:27 -0800 Subject: HelpChannels: add trace logging for notifications --- bot/cogs/help_channels.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index ab2f5b8c3..d226b201a 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -454,6 +454,8 @@ class HelpChannels(Scheduler, commands.Cog): if not constants.HelpChannels.notify: return + log.trace("Notifying about lack of channels.") + if self.last_notification: elapsed = (datetime.utcnow() - self.last_notification).seconds minimum_interval = constants.HelpChannels.notify_minutes * 60 @@ -462,9 +464,12 @@ class HelpChannels(Scheduler, commands.Cog): should_send = True if not should_send: + log.trace("Notification not sent because it's too recent since the previous one.") return try: + log.trace("Sending notification message.") + channel = self.bot.get_channel(constants.HelpChannels.notify_channel) mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_roles) -- cgit v1.2.3 From fc5bda29a0cb6beee931d48a83bc5f093a22499c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 28 Feb 2020 11:35:47 -0800 Subject: HelpChannels: cancel channel queue tasks on cog unload * Store queue get() tasks in a list * Create a separate function to wait for a channel from the queue * Add comments for the various groups of attributes defined in __init__ --- bot/cogs/help_channels.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index d226b201a..6bed3199c 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -92,16 +92,20 @@ class HelpChannels(Scheduler, commands.Cog): self.bot = bot + # Categories self.available_category: discord.CategoryChannel = None self.in_use_category: discord.CategoryChannel = None self.dormant_category: discord.CategoryChannel = None + # Queues self.channel_queue: asyncio.Queue[discord.TextChannel] = None self.name_queue: t.Deque[str] = None self.elements = self.get_names() self.last_notification: t.Optional[datetime] = None + # Asyncio stuff + self.queue_tasks: t.List[asyncio.Task] = [] self.ready = asyncio.Event() self.on_message_lock = asyncio.Lock() self.init_task = self.bot.loop.create_task(self.init_cog()) @@ -111,6 +115,10 @@ class HelpChannels(Scheduler, commands.Cog): log.trace("Cog unload: cancelling the cog_init task") self.init_task.cancel() + log.trace("Cog unload: cancelling the channel queue tasks") + for task in self.queue_tasks: + task.cancel() + log.trace("Cog unload: cancelling the scheduled tasks") for task in self.scheduled_tasks.values(): task.cancel() @@ -194,7 +202,7 @@ class HelpChannels(Scheduler, commands.Cog): if not channel: log.info("Couldn't create a candidate channel; waiting to get one from the queue.") await self.notify() - channel = await self.channel_queue.get() + await self.wait_for_dormant_channel() return channel @@ -535,6 +543,19 @@ class HelpChannels(Scheduler, commands.Cog): log.trace(f"Channel #{channel.name} ({channel_id}) retrieved.") return channel + async def wait_for_dormant_channel(self) -> discord.TextChannel: + """Wait for a dormant channel to become available in the queue and return it.""" + log.trace("Waiting for a dormant channel.") + + task = asyncio.create_task(self.channel_queue.get()) + self.queue_tasks.append(task) + channel = await task + + log.trace(f"Channel #{channel.name} ({channel.id}) finally retrieved from the queue.") + self.queue_tasks.remove(task) + + return channel + async def _scheduled_task(self, data: ChannelTimeout) -> None: """Make a channel dormant after specified timeout or reschedule if it's still active.""" log.trace(f"Waiting {data.timeout} seconds before making #{data.channel.name} dormant.") -- cgit v1.2.3 From 5becd8bffd299153d0d36685857aafa8ffb3a8e2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 29 Feb 2020 08:53:20 -0800 Subject: HelpChannels: prefix channel names after reading from file Prefixing them early on means subsequent code doesn't have to deal with stripping the prefix from channel names in order to get their positions. * Remove `count` parameter from `get_names`; define it in the body --- bot/cogs/help_channels.py | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 6bed3199c..d0ebce7a4 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -150,10 +150,9 @@ class HelpChannels(Scheduler, commands.Cog): Return None if no more channel names are available. """ log.trace("Getting a name for a new dormant channel.") - name = constants.HelpChannels.name_prefix try: - name += self.name_queue.popleft() + name = self.name_queue.popleft() except IndexError: log.debug("No more names available for new dormant channels.") return None @@ -214,13 +213,10 @@ class HelpChannels(Scheduler, commands.Cog): """ log.trace(f"Getting alphabetical position for #{channel.name} ({channel.id}).") - prefix = constants.HelpChannels.name_prefix - element = channel.name[len(prefix):] - try: - position = self.elements[element] + position = self.elements[channel.name] except KeyError: - log.warning(f"Channel #{channel.name} ({channel.id}) doesn't have the prefix {prefix}.") + log.warning(f"Channel #{channel.name} ({channel.id}) doesn't have a valid name.") position = None log.trace( @@ -241,8 +237,16 @@ class HelpChannels(Scheduler, commands.Cog): yield channel @staticmethod - def get_names(count: int = constants.HelpChannels.max_total_channels) -> t.Dict[str, int]: - """Return a dict with the first `count` element names and their alphabetical indices.""" + def get_names() -> t.Dict[str, int]: + """ + Return a truncated dict of prefixed element names and their alphabetical indices. + + The amount of names if configured with `HelpChannels.max_total_channels`. + The prefix is configured with `HelpChannels.name_prefix`. + """ + count = constants.HelpChannels.max_total_channels + prefix = constants.HelpChannels.name_prefix + log.trace(f"Getting the first {count} element names from JSON.") if count > MAX_CHANNELS_PER_CATEGORY: @@ -254,20 +258,20 @@ class HelpChannels(Scheduler, commands.Cog): with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: all_names = json.load(elements_file) - truncated_names = itertools.islice(all_names.items(), count) - return dict(truncated_names) + names = itertools.islice(all_names.items(), count) + if prefix: + names = ((prefix + name, pos) for name, pos in names) + + return dict(names) def get_used_names(self) -> t.Set[str]: """Return channels names which are already being used.""" log.trace("Getting channel names which are already being used.") - start_index = len(constants.HelpChannels.name_prefix) - names = set() for cat in (self.available_category, self.in_use_category, self.dormant_category): for channel in self.get_category_channels(cat): - name = channel.name[start_index:] - names.add(name) + names.add(channel.name) if len(names) > MAX_CHANNELS_PER_CATEGORY: log.warning( -- cgit v1.2.3 From 578850d483644c79ee3124dbf13ee43e234ac78a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 29 Feb 2020 08:54:04 -0800 Subject: HelpChannels: rename elements dict to name_positions --- bot/cogs/help_channels.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index d0ebce7a4..4c83e0722 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -101,7 +101,7 @@ class HelpChannels(Scheduler, commands.Cog): self.channel_queue: asyncio.Queue[discord.TextChannel] = None self.name_queue: t.Deque[str] = None - self.elements = self.get_names() + self.name_positions = self.get_names() self.last_notification: t.Optional[datetime] = None # Asyncio stuff @@ -167,7 +167,7 @@ class HelpChannels(Scheduler, commands.Cog): used_names = self.get_used_names() log.trace("Determining the available names.") - available_names = (name for name in self.elements if name not in used_names) + available_names = (name for name in self.name_positions if name not in used_names) log.trace("Populating the name queue with names.") return deque(available_names) @@ -214,7 +214,7 @@ class HelpChannels(Scheduler, commands.Cog): log.trace(f"Getting alphabetical position for #{channel.name} ({channel.id}).") try: - position = self.elements[channel.name] + position = self.name_positions[channel.name] except KeyError: log.warning(f"Channel #{channel.name} ({channel.id}) doesn't have a valid name.") position = None -- cgit v1.2.3 From 746673dbadb1a8b9872eeeeb21155214b846bba3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 29 Feb 2020 09:12:58 -0800 Subject: Scheduler: add a method to cancel all tasks The dictionary which was iterated to cancel tasks is now "private". Therefore, the scheduler should provide a public API for cancelling tasks. * Delete the task before cancelling it to prevent the done callback, however unlikely it may be, from deleting the task first --- bot/cogs/help_channels.py | 4 +--- bot/utils/scheduling.py | 9 ++++++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 4c83e0722..01bcc28f7 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -119,9 +119,7 @@ class HelpChannels(Scheduler, commands.Cog): for task in self.queue_tasks: task.cancel() - log.trace("Cog unload: cancelling the scheduled tasks") - for task in self.scheduled_tasks.values(): - task.cancel() + self.cancel_all() def create_channel_queue(self) -> asyncio.Queue: """ diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 5760ec2d4..e9a9e6c2d 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -60,11 +60,18 @@ class Scheduler(metaclass=CogABCMeta): log.warning(f"{self.cog_name}: failed to unschedule {task_id} (no task found).") return - task.cancel() del self._scheduled_tasks[task_id] + task.cancel() log.debug(f"{self.cog_name}: unscheduled task #{task_id} {id(task)}.") + def cancel_all(self) -> None: + """Unschedule all known tasks.""" + log.debug(f"{self.cog_name}: unscheduling all tasks") + + for task_id in self._scheduled_tasks: + self.cancel_task(task_id) + def _task_done_callback(self, task_id: t.Hashable, done_task: asyncio.Task) -> None: """ Delete the task and raise its exception if one exists. -- cgit v1.2.3 From 13d4c82ffafe8ba6cd0a6dab061bef02547d66b1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 29 Feb 2020 09:30:16 -0800 Subject: HelpChannels: explicitly specify if a task should be cancelled When rescheduling an idle channel, the task will only be cancelled if the function was told the channel should currently have a task scheduled. This means warnings for missing tasks will appear when they should. The previous approach of checking if a task exists was flawed because it had no way to tell whether a task *should* exist. It assumed nothing is wrong if a task doesn't exist. Currently, the only case when a task shouldn't exist is when the cog is initialised and channels from the bot's previous life are being scheduled. --- bot/cogs/help_channels.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 01bcc28f7..12c64c39b 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -351,7 +351,7 @@ class HelpChannels(Scheduler, commands.Cog): log.trace("Moving or rescheduling in-use channels.") for channel in self.get_category_channels(self.in_use_category): - await self.move_idle_channel(channel) + await self.move_idle_channel(channel, has_task=False) log.info("Cog is ready!") self.ready.set() @@ -365,11 +365,12 @@ class HelpChannels(Scheduler, commands.Cog): embed = message.embeds[0] return embed.description.strip() == DORMANT_MSG.strip() - async def move_idle_channel(self, channel: discord.TextChannel) -> None: + async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None: """ Make the `channel` dormant if idle or schedule the move if still active. - If a task to make the channel dormant already exists, it will first be cancelled. + If `has_task` is True and rescheduling is required, the extant task to make the channel + dormant will first be cancelled. """ log.trace(f"Handling in-use channel #{channel.name} ({channel.id}).") @@ -385,7 +386,7 @@ class HelpChannels(Scheduler, commands.Cog): await self.move_to_dormant(channel) else: # Cancel the existing task, if any. - if channel.id in self.scheduled_tasks: + if has_task: self.cancel_task(channel.id) data = ChannelTimeout(channel, idle_seconds - time_elapsed) -- cgit v1.2.3 From 2e7ac620027870ecbf21d3ff5e78553f96449eee Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 29 Feb 2020 09:30:37 -0800 Subject: HelpChannels: remove loop arg from schedule_task calls --- bot/cogs/help_channels.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 12c64c39b..fc944999f 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -396,7 +396,7 @@ class HelpChannels(Scheduler, commands.Cog): f"scheduling it to be moved after {data.timeout} seconds." ) - self.schedule_task(self.bot.loop, channel.id, data) + self.schedule_task(channel.id, data) async def move_to_available(self) -> None: """Make a channel available.""" @@ -449,7 +449,7 @@ class HelpChannels(Scheduler, commands.Cog): log.trace(f"Scheduling #{channel.name} ({channel.id}) to become dormant in {timeout} sec.") data = ChannelTimeout(channel, timeout) - self.schedule_task(self.bot.loop, channel.id, data) + self.schedule_task(channel.id, data) async def notify(self) -> None: """ -- cgit v1.2.3 From 0f36d442afc9633263d2a7f9a7d5f09aa6a5ac86 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 29 Feb 2020 09:39:24 -0800 Subject: HelpChannels: fix candidate channel not being returned after waiting --- 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 fc944999f..8da07e9f7 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -199,7 +199,7 @@ class HelpChannels(Scheduler, commands.Cog): if not channel: log.info("Couldn't create a candidate channel; waiting to get one from the queue.") await self.notify() - await self.wait_for_dormant_channel() + channel = await self.wait_for_dormant_channel() return channel -- cgit v1.2.3 From 0a5e6662fc65f1c097d5daca48d4856fa4839a58 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 29 Feb 2020 09:44:22 -0800 Subject: HelpChannels: cancel existing task in the dormant command --- bot/cogs/help_channels.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 8da07e9f7..dfc9e0119 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -178,6 +178,7 @@ class HelpChannels(Scheduler, commands.Cog): in_use = self.get_category_channels(self.in_use_category) if ctx.channel in in_use: + self.cancel_task(ctx.channel.id) await self.move_to_dormant(ctx.channel) else: log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") -- cgit v1.2.3 From 2225bfe97adf9547fc5817878778dcb7c62b2dd4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 29 Feb 2020 09:53:00 -0800 Subject: Scheduler: fix incorrect task id in error log --- bot/utils/scheduling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index e9a9e6c2d..f8b9d2d48 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -105,6 +105,6 @@ class Scheduler(metaclass=CogABCMeta): # Log the exception if one exists. if exception: log.error( - f"{self.cog_name}: error in task #{task_id} {id(scheduled_task)}!", + f"{self.cog_name}: error in task #{task_id} {id(done_task)}!", exc_info=exception ) -- cgit v1.2.3 From 12eac1690e310b87be32295858b12410276ff153 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 29 Feb 2020 10:18:08 -0800 Subject: HelpChannels: fix task cancelling itself --- bot/cogs/help_channels.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index dfc9e0119..0e3b1e893 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -569,8 +569,6 @@ class HelpChannels(Scheduler, commands.Cog): # The parent task (_scheduled_task) will still get cancelled. await asyncio.shield(self.move_idle_channel(data.channel)) - self.cancel_task(data.channel.id) - def setup(bot: Bot) -> None: """Load the HelpChannels cog.""" -- cgit v1.2.3 From 825da2a9d24b04ea8e319e0bd7972465934ba6b5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 29 Feb 2020 10:20:57 -0800 Subject: HelpChannels: fix last notification time not being set --- bot/cogs/help_channels.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 0e3b1e893..b215ed3d7 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -485,11 +485,13 @@ class HelpChannels(Scheduler, commands.Cog): channel = self.bot.get_channel(constants.HelpChannels.notify_channel) mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_roles) - await channel.send( + message = await channel.send( f"{mentions} A new available help channel is needed but there " f"are no more dormant ones. Consider freeing up some in-use channels manually by " f"using the `!dormant` command within the channels." ) + + self.last_notification = message.created_at except Exception: # Handle it here cause this feature isn't critical for the functionality of the system. log.exception("Failed to send notification about lack of dormant channels!") -- cgit v1.2.3 From 92df4ba80ba85fb9735d8ab6867da4f72e5ed87a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 29 Feb 2020 10:31:02 -0800 Subject: Scheduler: fix dict size changing while cancelling all tasks * Make a copy of the dict * Add a `ignore_missing` param to `cancel_task` to suppress the warning for a task not being found --- bot/utils/scheduling.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index f8b9d2d48..8b778a093 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -51,13 +51,18 @@ class Scheduler(metaclass=CogABCMeta): self._scheduled_tasks[task_id] = task log.debug(f"{self.cog_name}: scheduled task #{task_id} {id(task)}.") - def cancel_task(self, task_id: t.Hashable) -> None: - """Unschedule the task identified by `task_id`.""" + def cancel_task(self, task_id: t.Hashable, ignore_missing: bool = False) -> None: + """ + Unschedule the task identified by `task_id`. + + If `ignore_missing` is True, a warning will not be sent if a task isn't found. + """ log.trace(f"{self.cog_name}: cancelling task #{task_id}...") task = self._scheduled_tasks.get(task_id) if not task: - log.warning(f"{self.cog_name}: failed to unschedule {task_id} (no task found).") + if not ignore_missing: + log.warning(f"{self.cog_name}: failed to unschedule {task_id} (no task found).") return del self._scheduled_tasks[task_id] @@ -69,8 +74,8 @@ class Scheduler(metaclass=CogABCMeta): """Unschedule all known tasks.""" log.debug(f"{self.cog_name}: unscheduling all tasks") - for task_id in self._scheduled_tasks: - self.cancel_task(task_id) + for task_id in self._scheduled_tasks.copy(): + self.cancel_task(task_id, ignore_missing=True) def _task_done_callback(self, task_id: t.Hashable, done_task: asyncio.Task) -> None: """ -- cgit v1.2.3 From 90ddb10c9d02870b5cd6ad4b491d04665865cb9d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 29 Feb 2020 10:49:51 -0800 Subject: HelpChannels: disable the dormant command until cog is ready The ready event wasn't used because channels could change categories between the time the command is invoked and the cog is ready (e.g. if move_idle_channel wasn't called yet). his may confused users. So would potentially long delays for the cog to become ready. --- bot/cogs/help_channels.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index b215ed3d7..d769b2619 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -112,7 +112,7 @@ class HelpChannels(Scheduler, commands.Cog): def cog_unload(self) -> None: """Cancel the init task and scheduled tasks when the cog unloads.""" - log.trace("Cog unload: cancelling the cog_init task") + log.trace("Cog unload: cancelling the init_cog task") self.init_task.cancel() log.trace("Cog unload: cancelling the channel queue tasks") @@ -170,7 +170,7 @@ class HelpChannels(Scheduler, commands.Cog): log.trace("Populating the name queue with names.") return deque(available_names) - @commands.command(name="dormant") + @commands.command(name="dormant", enabled=False) @with_role(*constants.HelpChannels.cmd_whitelist) async def dormant_command(self, ctx: commands.Context) -> None: """Make the current in-use help channel dormant.""" @@ -354,6 +354,12 @@ class HelpChannels(Scheduler, commands.Cog): for channel in self.get_category_channels(self.in_use_category): await self.move_idle_channel(channel, has_task=False) + # Prevent the command from being used until ready. + # The ready event wasn't used because channels could change categories between the time + # the command is invoked and the cog is ready (e.g. if move_idle_channel wasn't called yet). + # This may confused users. So would potentially long delays for the cog to become ready. + self.dormant_command.enabled = True + log.info("Cog is ready!") self.ready.set() -- cgit v1.2.3 From 6774bb93836c2264d015ff9b9625dae6a91b1d56 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 29 Feb 2020 11:08:07 -0800 Subject: HelpChannels: initialise available channels after moving idle ones This will ensure the maximum amount of dormant channels possible before attempting to move any to the available category. It also allows the dormant command to already be enabled in case there are still no dormant channels when trying to init available channels. --- bot/cogs/help_channels.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index d769b2619..2fd5cc851 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -348,8 +348,6 @@ class HelpChannels(Scheduler, commands.Cog): self.channel_queue = self.create_channel_queue() self.name_queue = self.create_name_queue() - await self.init_available() - log.trace("Moving or rescheduling in-use channels.") for channel in self.get_category_channels(self.in_use_category): await self.move_idle_channel(channel, has_task=False) @@ -360,6 +358,8 @@ class HelpChannels(Scheduler, commands.Cog): # This may confused users. So would potentially long delays for the cog to become ready. self.dormant_command.enabled = True + await self.init_available() + log.info("Cog is ready!") self.ready.set() -- cgit v1.2.3 From 4a87de480c2dc7b23df8ab1d17e39c8c09bcef9f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 29 Feb 2020 11:53:28 -0800 Subject: HelpChannels: prevent cog load if config is invalid They must be greater than 0 because the cog obviously couldn't do anything without any channels to work with. It must be greater than max_available because it'd otherwise be impossible to maintain that many channels in the Available category. * Create a new function to validate the value * Move validation against MAX_CHANNELS_PER_CATEGORY into the function rather than just logging a warning --- bot/cogs/help_channels.py | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 2fd5cc851..314eefa00 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -248,12 +248,6 @@ class HelpChannels(Scheduler, commands.Cog): log.trace(f"Getting the first {count} element names from JSON.") - if count > MAX_CHANNELS_PER_CATEGORY: - log.warning( - f"{count} is too many help channels to make available! " - f"Discord only supports at most {MAX_CHANNELS_PER_CATEGORY} channels per category." - ) - with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: all_names = json.load(elements_file) @@ -578,6 +572,33 @@ class HelpChannels(Scheduler, commands.Cog): await asyncio.shield(self.move_idle_channel(data.channel)) +def validate_config() -> None: + """Raise a ValueError if the cog's config is invalid.""" + log.trace("Validating config.") + total = constants.HelpChannels.max_total_channels + available = constants.HelpChannels.max_available + + if total == 0 or available == 0: + raise ValueError("max_total_channels and max_available and must be greater than 0.") + + if total < available: + raise ValueError( + f"max_total_channels ({total}) must be greater than or equal to max_available " + f"({available})." + ) + + if total > MAX_CHANNELS_PER_CATEGORY: + raise ValueError( + f"max_total_channels ({total}) must be less than or equal to " + f"{MAX_CHANNELS_PER_CATEGORY} due to Discord's limit on channels per category." + ) + + def setup(bot: Bot) -> None: """Load the HelpChannels cog.""" - bot.add_cog(HelpChannels(bot)) + try: + validate_config() + except ValueError as e: + log.error(f"HelpChannels cog will not be loaded due to misconfiguration: {e}") + else: + bot.add_cog(HelpChannels(bot)) -- cgit v1.2.3 From 4d43b63a8c72594ac52cfd8f7e51e2504cf81ce0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 29 Feb 2020 12:26:12 -0800 Subject: HelpChannels: create generic way to schedule any awaitable To support scheduling different coroutines, `_scheduled_task` now accepts an awaitable in the data arg. The data arg is actually a named tuple of the wait time and the awaitable. --- bot/cogs/help_channels.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 314eefa00..5b8de156e 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -48,11 +48,11 @@ through [our guide for asking a good question]({ASKING_GUIDE_URL}). """ -class ChannelTimeout(t.NamedTuple): - """Data for a task scheduled to make a channel dormant.""" +class TaskData(t.NamedTuple): + """Data for a scheduled task.""" - channel: discord.TextChannel - timeout: int + wait_time: int + callback: t.Awaitable class HelpChannels(Scheduler, commands.Cog): @@ -390,11 +390,11 @@ class HelpChannels(Scheduler, commands.Cog): if has_task: self.cancel_task(channel.id) - data = ChannelTimeout(channel, idle_seconds - time_elapsed) + data = TaskData(idle_seconds - time_elapsed, self.move_idle_channel(channel)) log.info( f"#{channel.name} ({channel.id}) is still active; " - f"scheduling it to be moved after {data.timeout} seconds." + f"scheduling it to be moved after {data.wait_time} seconds." ) self.schedule_task(channel.id, data) @@ -449,7 +449,7 @@ class HelpChannels(Scheduler, commands.Cog): timeout = constants.HelpChannels.idle_minutes * 60 log.trace(f"Scheduling #{channel.name} ({channel.id}) to become dormant in {timeout} sec.") - data = ChannelTimeout(channel, timeout) + data = TaskData(timeout, self.move_idle_channel(channel)) self.schedule_task(channel.id, data) async def notify(self) -> None: @@ -562,14 +562,14 @@ class HelpChannels(Scheduler, commands.Cog): return channel - async def _scheduled_task(self, data: ChannelTimeout) -> None: - """Make a channel dormant after specified timeout or reschedule if it's still active.""" - log.trace(f"Waiting {data.timeout} seconds before making #{data.channel.name} dormant.") - await asyncio.sleep(data.timeout) + async def _scheduled_task(self, data: TaskData) -> None: + """Await the `data.callback` coroutine after waiting for `data.wait_time` seconds.""" + log.trace(f"Waiting {data.wait_time} seconds before awaiting callback.") + await asyncio.sleep(data.wait_time) - # Use asyncio.shield to prevent move_idle_channel from cancelling itself. + # Use asyncio.shield to prevent callback from cancelling itself. # The parent task (_scheduled_task) will still get cancelled. - await asyncio.shield(self.move_idle_channel(data.channel)) + await asyncio.shield(data.callback) def validate_config() -> None: -- cgit v1.2.3 From 6173072011b69c54fe817f383b70487e0ad97dee Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 29 Feb 2020 12:37:30 -0800 Subject: HelpChannels: allow users to claim a new channel every 15 minutes --- bot/cogs/help_channels.py | 20 ++++++++++++++++++++ bot/constants.py | 1 + config-default.yml | 3 +++ 3 files changed, 24 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 5b8de156e..e6401b14b 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -515,6 +515,7 @@ class HelpChannels(Scheduler, commands.Cog): return # Ignore messages outside the Available category. await self.move_to_in_use(message.channel) + await self.revoke_send_permissions(message.author) log.trace("Releasing on_message lock.") # Move a dormant channel to the Available category to fill in the gap. @@ -522,6 +523,25 @@ class HelpChannels(Scheduler, commands.Cog): # be put in the queue. await self.move_to_available() + async def revoke_send_permissions(self, member: discord.Member) -> None: + """ + Disallow `member` to send messages in the Available category for a certain time. + + The time until permissions are reinstated can be configured with + `HelpChannels.claim_minutes`. + """ + log.trace( + f"Revoking {member}'s ({member.id}) send message permissions in the Available category." + ) + + await self.available_category.set_permissions(member, send_messages=False) + + timeout = constants.HelpChannels.claim_minutes * 60 + callback = self.available_category.set_permissions(member, send_messages=None) + + log.trace(f"Scheduling {member}'s ({member.id}) send message permissions to be reinstated.") + self.schedule_task(member.id, TaskData(timeout, callback)) + async def send_available_message(self, channel: discord.TextChannel) -> None: """Send the available message by editing a dormant message or sending a new message.""" channel_info = f"#{channel.name} ({channel.id})" diff --git a/bot/constants.py b/bot/constants.py index 7f19f8a0e..8e9d40e8d 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -529,6 +529,7 @@ class Free(metaclass=YAMLGetter): class HelpChannels(metaclass=YAMLGetter): section = 'help_channels' + claim_minutes: int cmd_whitelist: List[int] idle_minutes: int max_available: int diff --git a/config-default.yml b/config-default.yml index b91df4580..a62572b70 100644 --- a/config-default.yml +++ b/config-default.yml @@ -505,6 +505,9 @@ mention: reset_delay: 5 help_channels: + # Minimum interval before allowing a certain user to claim a new help channel + claim_minutes: 15 + # Roles which are allowed to use the command which makes channels dormant cmd_whitelist: - *HELPERS_ROLE -- cgit v1.2.3 From d29afb8c710b20a46475546e4dd0cd950c51ab5f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 29 Feb 2020 12:45:42 -0800 Subject: HelpChannels: reset send permissions This ensures everyone has a clean slate when the bot restarts or the cog reloads since the tasks to reinstate permissions would have been cancelled in those cases. --- bot/cogs/help_channels.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index e6401b14b..ee8eb2e1c 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -338,6 +338,7 @@ class HelpChannels(Scheduler, commands.Cog): log.trace("Initialising the cog.") await self.init_categories() + await self.reset_send_permissions() self.channel_queue = self.create_channel_queue() self.name_queue = self.create_name_queue() @@ -523,6 +524,15 @@ class HelpChannels(Scheduler, commands.Cog): # be put in the queue. await self.move_to_available() + async def reset_send_permissions(self) -> None: + """Reset send permissions for members with it set to False in the Available category.""" + log.trace("Resetting send permissions in the Available category.") + + for member, overwrite in self.available_category.overwrites.items(): + if isinstance(member, discord.Member) and overwrite.send_messages is False: + log.trace(f"Resetting send permissions for {member} ({member.id}).") + await self.available_category.set_permissions(member, send_messages=None) + async def revoke_send_permissions(self, member: discord.Member) -> None: """ Disallow `member` to send messages in the Available category for a certain time. -- cgit v1.2.3 From ca5415629e3721a31568089eae362226eb07f561 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 29 Feb 2020 12:54:53 -0800 Subject: HelpChannels: include info about claim cooldowns in available message --- bot/cogs/help_channels.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index ee8eb2e1c..8dd17e936 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -31,6 +31,9 @@ question into it. Once claimed, the channel will move into the **Help: In Use** be yours until it has been inactive for {constants.HelpChannels.idle_minutes} minutes. When that \ happens, it will be set to **dormant** and moved into the **Help: Dormant** category. +You may claim a new channel once every {constants.HelpChannels.claim_minutes} minutes. If you \ +currently cannot send a message in this channel, it means you are on cooldown and need to wait. + Try to write the best question you can by providing a detailed description and telling us what \ you've tried already. For more information on asking a good question, \ [check out our guide on asking good questions]({ASKING_GUIDE_URL}). -- cgit v1.2.3 From b020e375a00ed2924ccd9be964326326a3737d4f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 29 Feb 2020 14:09:39 -0800 Subject: HelpChannels: make category checks direct & efficient Replace retrieval of all channels of a category with a direct comparison of the categories themselves. In the case of the `on_message` listener, the change enables the check to be done before the lock acquisition. This is because it doesn't rely on the channels in the category to be up to date. In fact, it doesn't even need the category object so it can exit early without needing to wait for the cog to be ready. --- bot/cogs/help_channels.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 8dd17e936..6ed66b80a 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -179,8 +179,7 @@ class HelpChannels(Scheduler, commands.Cog): """Make the current in-use help channel dormant.""" log.trace("dormant command invoked; checking if the channel is in-use.") - in_use = self.get_category_channels(self.in_use_category) - if ctx.channel in in_use: + if ctx.channel.category == self.in_use_category: self.cancel_task(ctx.channel.id) await self.move_to_dormant(ctx.channel) else: @@ -506,21 +505,28 @@ class HelpChannels(Scheduler, commands.Cog): if message.author.bot: return # Ignore messages sent by bots. + channel = message.channel + if channel.category and channel.category.id != constants.Categories.help_available: + return # Ignore messages outside the Available category. + log.trace("Waiting for the cog to be ready before processing messages.") await self.ready.wait() log.trace("Acquiring lock to prevent a channel from being processed twice...") async with self.on_message_lock: - log.trace("on_message lock acquired.") - log.trace("Checking if the message was sent in an available channel.") + log.trace(f"on_message lock acquired for {message.id}.") - available_channels = self.get_category_channels(self.available_category) - if message.channel not in available_channels: - return # Ignore messages outside the Available category. + if channel.category and channel.category.id != constants.Categories.help_available: + log.debug( + f"Message {message.id} will not make #{channel} ({channel.id}) in-use " + f"because another message in the channel already triggered that." + ) + return - await self.move_to_in_use(message.channel) + await self.move_to_in_use(channel) await self.revoke_send_permissions(message.author) - log.trace("Releasing on_message lock.") + + log.trace(f"Releasing on_message lock for {message.id}.") # Move a dormant channel to the Available category to fill in the gap. # This is done last and outside the lock because it may wait indefinitely for a channel to -- cgit v1.2.3 From 940b82c14186215083b7b37e6b06a525b9fbd924 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 29 Feb 2020 14:12:29 -0800 Subject: HelpChannels: remove name attribute access for channels in logs Can rely on `__str__` already being a channel's name. --- bot/cogs/help_channels.py | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 6ed66b80a..5dbc40b6a 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -212,16 +212,16 @@ class HelpChannels(Scheduler, commands.Cog): If the channel does not have a valid name with a chemical element, return None. """ - log.trace(f"Getting alphabetical position for #{channel.name} ({channel.id}).") + log.trace(f"Getting alphabetical position for #{channel} ({channel.id}).") try: position = self.name_positions[channel.name] except KeyError: - log.warning(f"Channel #{channel.name} ({channel.id}) doesn't have a valid name.") + log.warning(f"Channel #{channel} ({channel.id}) doesn't have a valid name.") position = None log.trace( - f"Position of #{channel.name} ({channel.id}) in Dormant will be {position} " + f"Position of #{channel} ({channel.id}) in Dormant will be {position} " f"(was {channel.position})." ) @@ -230,7 +230,7 @@ class HelpChannels(Scheduler, commands.Cog): @staticmethod def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: """Yield the text channels of the `category` in an unsorted manner.""" - log.trace(f"Getting text channels in the category '{category.name}' ({category.id}).") + log.trace(f"Getting text channels in the category '{category}' ({category.id}).") # This is faster than using category.channels because the latter sorts them. for channel in category.guild.channels: @@ -284,27 +284,27 @@ class HelpChannels(Scheduler, commands.Cog): Return None if the channel has no messages. """ - log.trace(f"Getting the idle time for #{channel.name} ({channel.id}).") + log.trace(f"Getting the idle time for #{channel} ({channel.id}).") msg = await cls.get_last_message(channel) if not msg: - log.debug(f"No idle time available; #{channel.name} ({channel.id}) has no messages.") + log.debug(f"No idle time available; #{channel} ({channel.id}) has no messages.") return None idle_time = (datetime.utcnow() - msg.created_at).seconds - log.trace(f"#{channel.name} ({channel.id}) has been idle for {idle_time} seconds.") + log.trace(f"#{channel} ({channel.id}) has been idle for {idle_time} seconds.") return idle_time @staticmethod async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.Message]: """Return the last message sent in the channel or None if no messages exist.""" - log.trace(f"Getting the last message in #{channel.name} ({channel.id}).") + log.trace(f"Getting the last message in #{channel} ({channel.id}).") try: return await channel.history(limit=1).next() # noqa: B305 except discord.NoMoreItems: - log.debug(f"No last message available; #{channel.name} ({channel.id}) has no messages.") + log.debug(f"No last message available; #{channel} ({channel.id}) has no messages.") return None async def init_available(self) -> None: @@ -376,14 +376,14 @@ class HelpChannels(Scheduler, commands.Cog): If `has_task` is True and rescheduling is required, the extant task to make the channel dormant will first be cancelled. """ - log.trace(f"Handling in-use channel #{channel.name} ({channel.id}).") + log.trace(f"Handling in-use channel #{channel} ({channel.id}).") idle_seconds = constants.HelpChannels.idle_minutes * 60 time_elapsed = await self.get_idle_time(channel) if time_elapsed is None or time_elapsed >= idle_seconds: log.info( - f"#{channel.name} ({channel.id}) is idle longer than {idle_seconds} seconds " + f"#{channel} ({channel.id}) is idle longer than {idle_seconds} seconds " f"and will be made dormant." ) @@ -396,7 +396,7 @@ class HelpChannels(Scheduler, commands.Cog): data = TaskData(idle_seconds - time_elapsed, self.move_idle_channel(channel)) log.info( - f"#{channel.name} ({channel.id}) is still active; " + f"#{channel} ({channel.id}) is still active; " f"scheduling it to be moved after {data.wait_time} seconds." ) @@ -407,11 +407,11 @@ class HelpChannels(Scheduler, commands.Cog): log.trace("Making a channel available.") channel = await self.get_available_candidate() - log.info(f"Making #{channel.name} ({channel.id}) available.") + log.info(f"Making #{channel} ({channel.id}) available.") await self.send_available_message(channel) - log.trace(f"Moving #{channel.name} ({channel.id}) to the Available category.") + log.trace(f"Moving #{channel} ({channel.id}) to the Available category.") await channel.edit( category=self.available_category, sync_permissions=True, @@ -420,7 +420,7 @@ class HelpChannels(Scheduler, commands.Cog): async def move_to_dormant(self, channel: discord.TextChannel) -> None: """Make the `channel` dormant.""" - log.info(f"Moving #{channel.name} ({channel.id}) to the Dormant category.") + log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") await channel.edit( category=self.dormant_category, @@ -429,18 +429,18 @@ class HelpChannels(Scheduler, commands.Cog): position=self.get_alphabetical_position(channel), ) - log.trace(f"Position of #{channel.name} ({channel.id}) is actually {channel.position}.") + log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.") - log.trace(f"Sending dormant message for #{channel.name} ({channel.id}).") + log.trace(f"Sending dormant message for #{channel} ({channel.id}).") embed = discord.Embed(description=DORMANT_MSG) await channel.send(embed=embed) - log.trace(f"Pushing #{channel.name} ({channel.id}) into the channel queue.") + log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") self.channel_queue.put_nowait(channel) async def move_to_in_use(self, channel: discord.TextChannel) -> None: """Make a channel in-use and schedule it to be made dormant.""" - log.info(f"Moving #{channel.name} ({channel.id}) to the In Use category.") + log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") await channel.edit( category=self.in_use_category, @@ -451,7 +451,7 @@ class HelpChannels(Scheduler, commands.Cog): timeout = constants.HelpChannels.idle_minutes * 60 - log.trace(f"Scheduling #{channel.name} ({channel.id}) to become dormant in {timeout} sec.") + log.trace(f"Scheduling #{channel} ({channel.id}) to become dormant in {timeout} sec.") data = TaskData(timeout, self.move_idle_channel(channel)) self.schedule_task(channel.id, data) @@ -563,7 +563,7 @@ class HelpChannels(Scheduler, commands.Cog): async def send_available_message(self, channel: discord.TextChannel) -> None: """Send the available message by editing a dormant message or sending a new message.""" - channel_info = f"#{channel.name} ({channel.id})" + channel_info = f"#{channel} ({channel.id})" log.trace(f"Sending available message in {channel_info}.") embed = discord.Embed(description=AVAILABLE_MSG) @@ -585,7 +585,7 @@ class HelpChannels(Scheduler, commands.Cog): log.debug(f"Channel {channel_id} is not in cache; fetching from API.") channel = await self.bot.fetch_channel(channel_id) - log.trace(f"Channel #{channel.name} ({channel_id}) retrieved.") + log.trace(f"Channel #{channel} ({channel_id}) retrieved.") return channel async def wait_for_dormant_channel(self) -> discord.TextChannel: @@ -596,7 +596,7 @@ class HelpChannels(Scheduler, commands.Cog): self.queue_tasks.append(task) channel = await task - log.trace(f"Channel #{channel.name} ({channel.id}) finally retrieved from the queue.") + log.trace(f"Channel #{channel} ({channel.id}) finally retrieved from the queue.") self.queue_tasks.remove(task) return channel -- cgit v1.2.3 From 249d13e9d25b4ab4a1207c65aa9c0ee59e55b733 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 29 Feb 2020 15:33:54 -0800 Subject: HelpChannels: fix unawaited coro warning Explicitly close the callback if it's a coroutine. --- bot/cogs/help_channels.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 5dbc40b6a..d6031d7ff 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -1,4 +1,5 @@ import asyncio +import inspect import itertools import json import logging @@ -603,12 +604,18 @@ class HelpChannels(Scheduler, commands.Cog): async def _scheduled_task(self, data: TaskData) -> None: """Await the `data.callback` coroutine after waiting for `data.wait_time` seconds.""" - log.trace(f"Waiting {data.wait_time} seconds before awaiting callback.") - await asyncio.sleep(data.wait_time) - - # Use asyncio.shield to prevent callback from cancelling itself. - # The parent task (_scheduled_task) will still get cancelled. - await asyncio.shield(data.callback) + try: + log.trace(f"Waiting {data.wait_time} seconds before awaiting callback.") + await asyncio.sleep(data.wait_time) + + # Use asyncio.shield to prevent callback from cancelling itself. + # The parent task (_scheduled_task) will still get cancelled. + log.trace("Done waiting; now awaiting the callback.") + await asyncio.shield(data.callback) + finally: + if inspect.iscoroutine(data.callback): + log.trace("Explicitly closing coroutine.") + data.callback.close() def validate_config() -> None: -- cgit v1.2.3 From a3f67c5361f1acb8f4aa022b7a59209c2f175412 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 29 Feb 2020 15:45:23 -0800 Subject: HelpChannels: fix unawaited coro warning for set_permissions This was happening when attempting to schedule a task twice for a user. Because the scheduler refuses to schedule a duplicate, the coroutine is deallocated right away without being awaited (or closed explicitly by `scheduled_task`). To fix, any existing task is cancelled before scheduling. This also means if somehow a user bypasses the lack of permissions, their cooldown will be updated. However, it probably doesn't make a difference as if they can bypass once, they likely can bypass again. --- bot/cogs/help_channels.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index d6031d7ff..6725efb72 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -556,6 +556,10 @@ class HelpChannels(Scheduler, commands.Cog): await self.available_category.set_permissions(member, send_messages=False) + # Cancel the existing task, if any. + # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner). + self.cancel_task(member.id, ignore_missing=True) + timeout = constants.HelpChannels.claim_minutes * 60 callback = self.available_category.set_permissions(member, send_messages=None) -- cgit v1.2.3 From aa5b6e9609ee00928d1591124eb92748879072df Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 1 Mar 2020 13:18:18 -0800 Subject: HelpChannels: use constant for command prefix in notification --- 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 6725efb72..7d793c73a 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -492,7 +492,7 @@ class HelpChannels(Scheduler, commands.Cog): message = await channel.send( f"{mentions} A new available help channel is needed but there " f"are no more dormant ones. Consider freeing up some in-use channels manually by " - f"using the `!dormant` command within the channels." + f"using the `{constants.Bot.prefix}dormant` command within the channels." ) self.last_notification = message.created_at -- cgit v1.2.3 From 9b1e1efc2a63945b173c380bf3dcda17d69be089 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 10 Mar 2020 13:03:45 -0700 Subject: HelpChannels: remove permission overwrites completely Resetting a specific permission still keeps the overwrite for the member around despite having default values. These will accumulate over time so they should be completely removed once the permission needs to be reset. --- 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 7d793c73a..973e6369a 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -561,7 +561,7 @@ class HelpChannels(Scheduler, commands.Cog): self.cancel_task(member.id, ignore_missing=True) timeout = constants.HelpChannels.claim_minutes * 60 - callback = self.available_category.set_permissions(member, send_messages=None) + callback = self.available_category.set_permissions(member, overwrite=None) log.trace(f"Scheduling {member}'s ({member.id}) send message permissions to be reinstated.") self.schedule_task(member.id, TaskData(timeout, callback)) -- cgit v1.2.3 From 943eddbee5b79bf848f76c6138e90b973d8ea0ce Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 10 Mar 2020 13:19:03 -0700 Subject: Resources: add newline to end of elements.json --- bot/resources/elements.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/elements.json b/bot/resources/elements.json index bc9047397..6ea4964aa 100644 --- a/bot/resources/elements.json +++ b/bot/resources/elements.json @@ -117,4 +117,4 @@ "livermorium": 54, "tennessine": 103, "oganesson": 71 -} \ No newline at end of file +} -- cgit v1.2.3 From 19b31434115d54f814f5d17a34a647c27cce536c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 22 Mar 2020 14:40:21 -0700 Subject: HelpChannels: write channel topics --- bot/cogs/help_channels.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 973e6369a..7f169fe59 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -19,13 +19,23 @@ from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) -# TODO: write the channel topics -AVAILABLE_TOPIC = "" -IN_USE_TOPIC = "" -DORMANT_TOPIC = "" ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" MAX_CHANNELS_PER_CATEGORY = 50 +AVAILABLE_TOPIC = """ +This channel is available. Feel free to ask a question in order to claim this channel! +""" + +IN_USE_TOPIC = """ +This channel is currently in use. If you'd like to discuss a different problem, please claim a new \ +channel from the Help: Available category. +""" + +DORMANT_TOPIC = """ +This channel is temporarily archived. If you'd like to ask a question, please use one of the \ +channels in the Help: Available category. +""" + AVAILABLE_MSG = f""" This help channel is now **available**, which means that you can claim it by simply typing your \ question into it. Once claimed, the channel will move into the **Help: In Use** category, and will \ -- cgit v1.2.3 From 5fba7ab0ca1bde23f028b654d0a69d0d48bec211 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 22 Mar 2020 14:43:59 -0700 Subject: HelpChannels: set idle minutes to 30 & max total channels to 32 --- config-default.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config-default.yml b/config-default.yml index a62572b70..d87ceade7 100644 --- a/config-default.yml +++ b/config-default.yml @@ -513,14 +513,14 @@ help_channels: - *HELPERS_ROLE # Allowed duration of inactivity before making a channel dormant - idle_minutes: 45 + idle_minutes: 30 # Maximum number of channels to put in the available category max_available: 2 # Maximum number of channels across all 3 categories # Note Discord has a hard limit of 50 channels per category, so this shouldn't be > 50 - max_total_channels: 50 + max_total_channels: 32 # Prefix for help channel names name_prefix: 'help-' -- cgit v1.2.3 From 5cfeec42aca39b3b6e6ec0739c71085daa987aca Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 22 Mar 2020 14:52:56 -0700 Subject: HelpChannels: mention the helper notifications in cog docstring --- bot/cogs/help_channels.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 7f169fe59..69af085ee 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -82,6 +82,7 @@ class HelpChannels(Scheduler, commands.Cog): * Prioritise using the channels which have been dormant for the longest amount of time * If there are no more dormant channels, the bot will automatically create a new one * Configurable with `constants.HelpChannels.max_available` + * If there are no dormant channels to move, helpers will be notified (see `notify()`) * When a channel becomes available, the dormant embed will be edited to show `AVAILABLE_MSG` In Use Category -- cgit v1.2.3 From ece898460abc2937e9b73d53d2f1f695069ef2e1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 22 Mar 2020 15:03:34 -0700 Subject: Constants: add a config value to toggle help channels extension --- bot/__main__.py | 13 +++++++------ bot/constants.py | 1 + config-default.yml | 2 ++ 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/bot/__main__.py b/bot/__main__.py index 30a7dee41..f6ca5a9c8 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -5,9 +5,8 @@ import sentry_sdk from discord.ext.commands import when_mentioned_or from sentry_sdk.integrations.logging import LoggingIntegration -from bot import patches +from bot import constants, patches from bot.bot import Bot -from bot.constants import Bot as BotConfig sentry_logging = LoggingIntegration( level=logging.DEBUG, @@ -15,12 +14,12 @@ sentry_logging = LoggingIntegration( ) sentry_sdk.init( - dsn=BotConfig.sentry_dsn, + dsn=constants.Bot.sentry_dsn, integrations=[sentry_logging] ) bot = Bot( - command_prefix=when_mentioned_or(BotConfig.prefix), + command_prefix=when_mentioned_or(constants.Bot.prefix), activity=discord.Game(name="Commands: !help"), case_insensitive=True, max_messages=10_000, @@ -49,7 +48,6 @@ bot.load_extension("bot.cogs.alias") bot.load_extension("bot.cogs.defcon") bot.load_extension("bot.cogs.eval") bot.load_extension("bot.cogs.duck_pond") -bot.load_extension("bot.cogs.help_channels") bot.load_extension("bot.cogs.information") bot.load_extension("bot.cogs.jams") bot.load_extension("bot.cogs.moderation") @@ -65,8 +63,11 @@ bot.load_extension("bot.cogs.utils") bot.load_extension("bot.cogs.watchchannels") bot.load_extension("bot.cogs.wolfram") +if constants.HelpChannels.enable: + bot.load_extension("bot.cogs.help_channels") + # Apply `message_edited_at` patch if discord.py did not yet release a bug fix. if not hasattr(discord.message.Message, '_handle_edited_timestamp'): patches.message_edited_at.apply_patch() -bot.run(BotConfig.token) +bot.run(constants.Bot.token) diff --git a/bot/constants.py b/bot/constants.py index 8e9d40e8d..da1a62780 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -529,6 +529,7 @@ class Free(metaclass=YAMLGetter): class HelpChannels(metaclass=YAMLGetter): section = 'help_channels' + enable: bool claim_minutes: int cmd_whitelist: List[int] idle_minutes: int diff --git a/config-default.yml b/config-default.yml index d87ceade7..12f69deca 100644 --- a/config-default.yml +++ b/config-default.yml @@ -505,6 +505,8 @@ mention: reset_delay: 5 help_channels: + enable: false + # Minimum interval before allowing a certain user to claim a new help channel claim_minutes: 15 -- cgit v1.2.3 From ece2e7a2c07d6de012052c3b44d9c9110125bcc8 Mon Sep 17 00:00:00 2001 From: Karlis S Date: Mon, 23 Mar 2020 05:51:09 +0000 Subject: Removed `zen` tag due `!zen` command exist. --- bot/resources/tags/zen.md | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 bot/resources/tags/zen.md diff --git a/bot/resources/tags/zen.md b/bot/resources/tags/zen.md deleted file mode 100644 index 3e132eed8..000000000 --- a/bot/resources/tags/zen.md +++ /dev/null @@ -1,20 +0,0 @@ - -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! -- cgit v1.2.3 From 8d3a10089a9691bf1c463cd5ec3f0527f5bbc0a5 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Mon, 23 Mar 2020 10:17:06 -0400 Subject: Clarify docstring for token check function Co-Authored-By: Mark --- bot/cogs/token_remover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index ad6d99e84..421ad23e2 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -95,7 +95,7 @@ class TokenRemover(Cog): @classmethod def find_token_in_message(cls, msg: Message) -> t.Optional[str]: - """Check for a seemingly valid token in the provided `Message` instance.""" + """Return a seemingly valid token found in `msg` or `None` if no token is found.""" if msg.author.bot: return -- cgit v1.2.3 From 30f8c8d6b4df87fbc8273126b7f110d1d3d33714 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Tue, 24 Mar 2020 13:25:37 -0400 Subject: Remove unused safety & dodgy dev dependencies Relock --- Pipfile | 2 - Pipfile.lock | 275 +++++++++++++++++++++-------------------------------------- 2 files changed, 98 insertions(+), 179 deletions(-) diff --git a/Pipfile b/Pipfile index 0dcee0e3d..04cc98427 100644 --- a/Pipfile +++ b/Pipfile @@ -33,9 +33,7 @@ flake8-tidy-imports = "~=4.0" flake8-todo = "~=0.7" pep8-naming = "~=0.9" pre-commit = "~=2.1" -safety = "~=1.8" unittest-xml-reporting = "~=3.0" -dodgy = "~=0.1" [requires] python_version = "3.8" diff --git a/Pipfile.lock b/Pipfile.lock index 348456f2c..ad9a3173a 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "b8b38e84230bdc37f8c8955e8dddc442183a2e23c4dfc6ed37c522644aecdeea" + "sha256": "2d3ba484e8467a115126b2ba39fa5f36f103ea455477813dd658797875c79cc9" }, "pipfile-spec": 6, "requires": { @@ -18,11 +18,11 @@ "default": { "aio-pika": { "hashes": [ - "sha256:0332bc13abbd8923dac657b331716778c55ea0a32ac0951306ce85edafcc916c", - "sha256:39770d8bc7e9059e28622d599e2ac9ebc16a7198b33d1743c1a496ca3b0f8170" + "sha256:9e4614636296e0040055bd6b304e97a38cc9796669ef391fc9b36649831d43ee", + "sha256:c9d242b3c7142d64b185feb6c5cce4154962610e89ec2e9b52bd69ef01f89b2f" ], "index": "pypi", - "version": "==6.5.3" + "version": "==6.6.0" }, "aiodns": { "hashes": [ @@ -159,11 +159,11 @@ }, "deepdiff": { "hashes": [ - "sha256:b3fa588d1eac7fa318ec1fb4f2004568e04cb120a1989feda8e5e7164bcbf07a", - "sha256:ed7342d3ed3c0c2058a3fb05b477c943c9959ef62223dca9baa3375718a25d87" + "sha256:59fc1e3e7a28dd0147b0f2b00e3e27181f0f0ef4286b251d5f214a5bcd9a9bc4", + "sha256:91360be1d9d93b1d9c13ae9c5048fa83d9cff17a88eb30afaa0d7ff2d0fee17d" ], "index": "pypi", - "version": "==4.2.0" + "version": "==4.3.2" }, "discord-py": { "hashes": [ @@ -189,10 +189,10 @@ }, "humanfriendly": { "hashes": [ - "sha256:2f79aaa2965c0fc3d79452e64ec2c7601d70d67e51ea2e99cb40afe3fe2824c5", - "sha256:6990c0af4b72f50ddf302900eb982edf199247e621e06d80d71b00b1a1574214" + "sha256:25c2108a45cfd1e8fbe9cdb30b825d34ef5d5675c8e11e4775c9aedbfb0bdee2", + "sha256:3a831920e40e55ad49adb64c9179ed50c604cabca72cd300e7bd5b51310e4ebb" ], - "version": "==8.0" + "version": "==8.1" }, "idna": { "hashes": [ @@ -331,10 +331,10 @@ }, "packaging": { "hashes": [ - "sha256:170748228214b70b672c581a3dd610ee51f733018650740e98c7df862a583f73", - "sha256:e665345f9eef0c621aa0bf2f8d78cf6d21904eef16a93f020240b704a57f1334" + "sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3", + "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752" ], - "version": "==20.1" + "version": "==20.3" }, "pamqp": { "hashes": [ @@ -379,16 +379,17 @@ }, "pycparser": { "hashes": [ - "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3" + "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", + "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" ], - "version": "==2.19" + "version": "==2.20" }, "pygments": { "hashes": [ - "sha256:2a3fe295e54a20164a9df49c75fa58526d3be48e14aceba6d6b1e8ac0bfd6f1b", - "sha256:98c8aa5a9f778fcd1026a17361ddaf7330d1b7c62ae97c3bb0ae73e0b9b6b0fe" + "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44", + "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324" ], - "version": "==2.5.2" + "version": "==2.6.1" }, "pyparsing": { "hashes": [ @@ -421,20 +422,20 @@ }, "pyyaml": { "hashes": [ - "sha256:059b2ee3194d718896c0ad077dd8c043e5e909d9180f387ce42012662a4946d6", - "sha256:1cf708e2ac57f3aabc87405f04b86354f66799c8e62c28c5fc5f88b5521b2dbf", - "sha256:24521fa2890642614558b492b473bee0ac1f8057a7263156b02e8b14c88ce6f5", - "sha256:4fee71aa5bc6ed9d5f116327c04273e25ae31a3020386916905767ec4fc5317e", - "sha256:70024e02197337533eef7b85b068212420f950319cc8c580261963aefc75f811", - "sha256:74782fbd4d4f87ff04159e986886931456a1894c61229be9eaf4de6f6e44b99e", - "sha256:940532b111b1952befd7db542c370887a8611660d2b9becff75d39355303d82d", - "sha256:cb1f2f5e426dc9f07a7681419fe39cee823bb74f723f36f70399123f439e9b20", - "sha256:dbbb2379c19ed6042e8f11f2a2c66d39cceb8aeace421bfc29d085d93eda3689", - "sha256:e3a057b7a64f1222b56e47bcff5e4b94c4f61faac04c7c4ecb1985e18caa3994", - "sha256:e9f45bd5b92c7974e59bcd2dcc8631a6b6cc380a904725fce7bc08872e691615" + "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", + "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", + "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", + "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", + "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", + "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", + "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", + "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", + "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", + "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", + "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" ], "index": "pypi", - "version": "==5.3" + "version": "==5.3.1" }, "requests": { "hashes": [ @@ -446,11 +447,11 @@ }, "sentry-sdk": { "hashes": [ - "sha256:480eee754e60bcae983787a9a13bc8f155a111aef199afaa4f289d6a76aa622a", - "sha256:a920387dc3ee252a66679d0afecd34479fb6fc52c2bc20763793ed69e5b0dcc0" + "sha256:23808d571d2461a4ce3784ec12bbee5bdb8c026c143fe79d36cef8a6d653e71f", + "sha256:bb90a4e19c7233a580715fc986cc44be2c48fc10b31e71580a2037e1c94b6950" ], "index": "pypi", - "version": "==0.14.2" + "version": "==0.14.3" }, "six": { "hashes": [ @@ -475,11 +476,11 @@ }, "sphinx": { "hashes": [ - "sha256:776ff8333181138fae52df65be733127539623bb46cc692e7fa0fcfc80d7aa88", - "sha256:ca762da97c3b5107cbf0ab9e11d3ec7ab8d3c31377266fd613b962ed971df709" + "sha256:b4c750d546ab6d7e05bdff6ac24db8ae3e8b8253a3569b754e445110a0a12b66", + "sha256:fc312670b56cb54920d6cc2ced455a22a547910de10b3142276495ced49231cb" ], "index": "pypi", - "version": "==2.4.3" + "version": "==2.4.4" }, "sphinxcontrib-applehelp": { "hashes": [ @@ -595,13 +596,6 @@ ], "version": "==19.3.0" }, - "certifi": { - "hashes": [ - "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", - "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" - ], - "version": "==2019.11.28" - }, "cfgv": { "hashes": [ "sha256:1ccf53320421aeeb915275a196e23b3b8ae87dea8ac6698b1638001d4a486d53", @@ -609,56 +603,42 @@ ], "version": "==3.1.0" }, - "chardet": { - "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" - ], - "version": "==3.0.4" - }, - "click": { - "hashes": [ - "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", - "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7" - ], - "version": "==7.0" - }, "coverage": { "hashes": [ - "sha256:15cf13a6896048d6d947bf7d222f36e4809ab926894beb748fc9caa14605d9c3", - "sha256:1daa3eceed220f9fdb80d5ff950dd95112cd27f70d004c7918ca6dfc6c47054c", - "sha256:1e44a022500d944d42f94df76727ba3fc0a5c0b672c358b61067abb88caee7a0", - "sha256:25dbf1110d70bab68a74b4b9d74f30e99b177cde3388e07cc7272f2168bd1477", - "sha256:3230d1003eec018ad4a472d254991e34241e0bbd513e97a29727c7c2f637bd2a", - "sha256:3dbb72eaeea5763676a1a1efd9b427a048c97c39ed92e13336e726117d0b72bf", - "sha256:5012d3b8d5a500834783689a5d2292fe06ec75dc86ee1ccdad04b6f5bf231691", - "sha256:51bc7710b13a2ae0c726f69756cf7ffd4362f4ac36546e243136187cfcc8aa73", - "sha256:527b4f316e6bf7755082a783726da20671a0cc388b786a64417780b90565b987", - "sha256:722e4557c8039aad9592c6a4213db75da08c2cd9945320220634f637251c3894", - "sha256:76e2057e8ffba5472fd28a3a010431fd9e928885ff480cb278877c6e9943cc2e", - "sha256:77afca04240c40450c331fa796b3eab6f1e15c5ecf8bf2b8bee9706cd5452fef", - "sha256:7afad9835e7a651d3551eab18cbc0fdb888f0a6136169fbef0662d9cdc9987cf", - "sha256:9bea19ac2f08672636350f203db89382121c9c2ade85d945953ef3c8cf9d2a68", - "sha256:a8b8ac7876bc3598e43e2603f772d2353d9931709345ad6c1149009fd1bc81b8", - "sha256:b0840b45187699affd4c6588286d429cd79a99d509fe3de0f209594669bb0954", - "sha256:b26aaf69713e5674efbde4d728fb7124e429c9466aeaf5f4a7e9e699b12c9fe2", - "sha256:b63dd43f455ba878e5e9f80ba4f748c0a2156dde6e0e6e690310e24d6e8caf40", - "sha256:be18f4ae5a9e46edae3f329de2191747966a34a3d93046dbdf897319923923bc", - "sha256:c312e57847db2526bc92b9bfa78266bfbaabac3fdcd751df4d062cd4c23e46dc", - "sha256:c60097190fe9dc2b329a0eb03393e2e0829156a589bd732e70794c0dd804258e", - "sha256:c62a2143e1313944bf4a5ab34fd3b4be15367a02e9478b0ce800cb510e3bbb9d", - "sha256:cc1109f54a14d940b8512ee9f1c3975c181bbb200306c6d8b87d93376538782f", - "sha256:cd60f507c125ac0ad83f05803063bed27e50fa903b9c2cfee3f8a6867ca600fc", - "sha256:d513cc3db248e566e07a0da99c230aca3556d9b09ed02f420664e2da97eac301", - "sha256:d649dc0bcace6fcdb446ae02b98798a856593b19b637c1b9af8edadf2b150bea", - "sha256:d7008a6796095a79544f4da1ee49418901961c97ca9e9d44904205ff7d6aa8cb", - "sha256:da93027835164b8223e8e5af2cf902a4c80ed93cb0909417234f4a9df3bcd9af", - "sha256:e69215621707119c6baf99bda014a45b999d37602cb7043d943c76a59b05bf52", - "sha256:ea9525e0fef2de9208250d6c5aeeee0138921057cd67fcef90fbed49c4d62d37", - "sha256:fca1669d464f0c9831fd10be2eef6b86f5ebd76c724d1e0706ebdff86bb4adf0" - ], - "index": "pypi", - "version": "==5.0.3" + "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" + ], + "index": "pypi", + "version": "==5.0.4" }, "distlib": { "hashes": [ @@ -666,21 +646,6 @@ ], "version": "==0.3.0" }, - "dodgy": { - "hashes": [ - "sha256:28323cbfc9352139fdd3d316fa17f325cc0e9ac74438cbba51d70f9b48f86c3a", - "sha256:51f54c0fd886fa3854387f354b19f429d38c04f984f38bc572558b703c0542a6" - ], - "index": "pypi", - "version": "==0.2.1" - }, - "dparse": { - "hashes": [ - "sha256:00a5fdfa900629e5159bf3600d44905b333f4059a3366f28e0dbd13eeab17b19", - "sha256:cef95156fa0adedaf042cd42f9990974bec76f25dfeca4dc01f381a243d5aa5b" - ], - "version": "==0.4.1" - }, "entrypoints": { "hashes": [ "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", @@ -752,11 +717,11 @@ }, "flake8-tidy-imports": { "hashes": [ - "sha256:8aa34384b45137d4cf33f5818b8e7897dc903b1d1e10a503fa7dd193a9a710ba", - "sha256:b26461561bcc80e8012e46846630ecf0aaa59314f362a94cb7800dfdb32fa413" + "sha256:5b6e75cec6d751e66534c522fbdce7dac1c2738b1216b0f6b10453995932e188", + "sha256:cf26fbb3ab31a398f265d53b6f711d80006450c19221e41b2b7b0e0b14ac39c5" ], "index": "pypi", - "version": "==4.0.0" + "version": "==4.0.1" }, "flake8-todo": { "hashes": [ @@ -767,17 +732,10 @@ }, "identify": { "hashes": [ - "sha256:1222b648251bdcb8deb240b294f450fbf704c7984e08baa92507e4ea10b436d5", - "sha256:d824ebe21f38325c771c41b08a95a761db1982f1fc0eee37c6c97df3f1636b96" + "sha256:a7577a1f55cee1d21953a5cf11a3c839ab87f5ef909a4cba6cf52ed72b4c6059", + "sha256:ab246293e6585a1c6361a505b68d5b501a0409310932b7de2c2ead667b564d89" ], - "version": "==1.4.11" - }, - "idna": { - "hashes": [ - "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", - "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" - ], - "version": "==2.9" + "version": "==1.4.13" }, "mccabe": { "hashes": [ @@ -792,28 +750,21 @@ ], "version": "==1.3.5" }, - "packaging": { - "hashes": [ - "sha256:170748228214b70b672c581a3dd610ee51f733018650740e98c7df862a583f73", - "sha256:e665345f9eef0c621aa0bf2f8d78cf6d21904eef16a93f020240b704a57f1334" - ], - "version": "==20.1" - }, "pep8-naming": { "hashes": [ - "sha256:45f330db8fcfb0fba57458c77385e288e7a3be1d01e8ea4268263ef677ceea5f", - "sha256:a33d38177056321a167decd6ba70b890856ba5025f0a8eca6a3eda607da93caf" + "sha256:5d9f1056cb9427ce344e98d1a7f5665710e2f20f748438e308995852cfa24164", + "sha256:f3b4a5f9dd72b991bf7d8e2a341d2e1aa3a884a769b5aaac4f56825c1763bf3a" ], "index": "pypi", - "version": "==0.9.1" + "version": "==0.10.0" }, "pre-commit": { "hashes": [ - "sha256:09ebe467f43ce24377f8c2f200fe3cd2570d328eb2ce0568c8e96ce19da45fa6", - "sha256:f8d555e31e2051892c7f7b3ad9f620bd2c09271d87e9eedb2ad831737d6211eb" + "sha256:487c675916e6f99d355ec5595ad77b325689d423ef4839db1ed2f02f639c9522", + "sha256:c0aa11bce04a7b46c5544723aedf4e81a4d5f64ad1205a30a9ea12d5e81969e1" ], "index": "pypi", - "version": "==2.1.1" + "version": "==2.2.0" }, "pycodestyle": { "hashes": [ @@ -836,45 +787,22 @@ ], "version": "==2.1.1" }, - "pyparsing": { - "hashes": [ - "sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f", - "sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec" - ], - "version": "==2.4.6" - }, "pyyaml": { "hashes": [ - "sha256:059b2ee3194d718896c0ad077dd8c043e5e909d9180f387ce42012662a4946d6", - "sha256:1cf708e2ac57f3aabc87405f04b86354f66799c8e62c28c5fc5f88b5521b2dbf", - "sha256:24521fa2890642614558b492b473bee0ac1f8057a7263156b02e8b14c88ce6f5", - "sha256:4fee71aa5bc6ed9d5f116327c04273e25ae31a3020386916905767ec4fc5317e", - "sha256:70024e02197337533eef7b85b068212420f950319cc8c580261963aefc75f811", - "sha256:74782fbd4d4f87ff04159e986886931456a1894c61229be9eaf4de6f6e44b99e", - "sha256:940532b111b1952befd7db542c370887a8611660d2b9becff75d39355303d82d", - "sha256:cb1f2f5e426dc9f07a7681419fe39cee823bb74f723f36f70399123f439e9b20", - "sha256:dbbb2379c19ed6042e8f11f2a2c66d39cceb8aeace421bfc29d085d93eda3689", - "sha256:e3a057b7a64f1222b56e47bcff5e4b94c4f61faac04c7c4ecb1985e18caa3994", - "sha256:e9f45bd5b92c7974e59bcd2dcc8631a6b6cc380a904725fce7bc08872e691615" + "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", + "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", + "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", + "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", + "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", + "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", + "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", + "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", + "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", + "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", + "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" ], "index": "pypi", - "version": "==5.3" - }, - "requests": { - "hashes": [ - "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", - "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" - ], - "index": "pypi", - "version": "==2.23.0" - }, - "safety": { - "hashes": [ - "sha256:0a3a8a178a9c96242b224f033ee8d1d130c0448b0e6622d12deaf37f6c3b4e59", - "sha256:5059f3ffab3648330548ea9c7403405bbfaf085b11235770825d14c58f24cb78" - ], - "index": "pypi", - "version": "==1.8.5" + "version": "==5.3.1" }, "six": { "hashes": [ @@ -905,19 +833,12 @@ "index": "pypi", "version": "==3.0.2" }, - "urllib3": { - "hashes": [ - "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", - "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" - ], - "version": "==1.25.8" - }, "virtualenv": { "hashes": [ - "sha256:30ea90b21dabd11da5f509710ad3be2ae47d40ccbc717dfdd2efe4367c10f598", - "sha256:4a36a96d785428278edd389d9c36d763c5755844beb7509279194647b1ef47f1" + "sha256:87831f1070534b636fea2241dd66f3afe37ac9041bcca6d0af3215cdcfbf7d82", + "sha256:f3128d882383c503003130389bf892856341c1da12c881ae24d6358c82561b55" ], - "version": "==20.0.7" + "version": "==20.0.13" } } } -- cgit v1.2.3 From 95db4c912787921cf8324e9f65ad4ee1bbb898bd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Mar 2020 10:11:28 -0700 Subject: CI: remove support for partial cache hits Partial hits may cause issues when packages get removed. The cache will get bloated with packages which are no longer needed. They will keep accumulating as more packages get removed unless the cache is unused for 7 days and gets automatically deleted by Azure Pipelines. Lingering packages are also a potential cause for conflicts (e.g. unused package x depends on package y==4.0 and useful package z depends on y==5.0). Removing support for partial hits means all dependencies will be installed whenever a single dependency changes. --- azure-pipelines.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 16e4489c0..d56675029 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -35,10 +35,6 @@ jobs: displayName: 'Restore Python environment' inputs: key: python | $(Agent.OS) | "$(python.pythonLocation)" | 0 | ./Pipfile | ./Pipfile.lock - restoreKeys: | - python | "$(python.pythonLocation)" | 0 | ./Pipfile.lock - python | "$(python.pythonLocation)" | 0 | ./Pipfile - python | "$(python.pythonLocation)" | 0 cacheHitVar: PY_ENV_RESTORED path: $(PYTHONUSERBASE) @@ -67,8 +63,6 @@ jobs: displayName: 'Restore pre-commit environment' inputs: key: pre-commit | "$(python.pythonLocation)" | 0 | .pre-commit-config.yaml - restoreKeys: | - pre-commit | "$(python.pythonLocation)" | 0 path: $(PRE_COMMIT_HOME) # pre-commit's venv doesn't allow user installs - not that they're really needed anyway. -- cgit v1.2.3 From 02e230ee3e3964a1eff891b493e1919cbb2f52be Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Mar 2020 12:07:10 -0700 Subject: Snekbox: fix re-eval when '!eval' is removed from edited message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous parsing method was naïve in assuming there would always be something preceding the code (e.g. the '!eval' command invocation) delimited by a space. Now it will only split if it's sure the eval command was used in the edited message. --- bot/cogs/snekbox.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index cff7c5786..454836921 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -232,7 +232,7 @@ class Snekbox(Cog): timeout=10 ) - code = new_message.content.split(' ', maxsplit=1)[1] + code = await self.get_code(new_message) await ctx.message.clear_reactions() with contextlib.suppress(HTTPException): await response.delete() @@ -243,6 +243,26 @@ class Snekbox(Cog): return code + async def get_code(self, message: Message) -> Optional[str]: + """ + Return the code from `message` to be evaluated. + + If the message is an invocation of the eval command, return the first argument or None if it + doesn't exist. Otherwise, return the full content of the message. + """ + log.trace(f"Getting context for message {message.id}.") + new_ctx = await self.bot.get_context(message) + + if new_ctx.command is self.eval_command: + log.trace(f"Message {message.id} invokes eval command.") + split = message.content.split(maxsplit=1) + code = split[1] if len(split) > 1 else None + else: + log.trace(f"Message {message.id} does not invoke eval command.") + code = message.content + + return code + @command(name="eval", aliases=("e",)) @guild_only() @in_channel(Channels.bot_commands, hidden_channels=(Channels.esoteric,), bypass_roles=EVAL_ROLES) -- cgit v1.2.3 From 430c616ec4ec60a5ddb1e66d3aacc622c9a78ae6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Mar 2020 12:21:57 -0700 Subject: Snekbox tests: test `get_code` Should return 1st arg (or None) if eval cmd in message, otherwise return full content. --- tests/bot/cogs/test_snekbox.py | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index fd9468829..1fad6904b 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -3,9 +3,11 @@ import logging import unittest from unittest.mock import AsyncMock, MagicMock, Mock, call, patch +from discord.ext import commands + +from bot import constants from bot.cogs import snekbox from bot.cogs.snekbox import Snekbox -from bot.constants import URLs from tests.helpers import MockBot, MockContext, MockMessage, MockReaction, MockUser @@ -23,7 +25,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(await self.cog.post_eval("import random"), "return") self.bot.http_session.post.assert_called_with( - URLs.snekbox_eval_api, + constants.URLs.snekbox_eval_api, json={"input": "import random"}, raise_for_status=True ) @@ -43,10 +45,10 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.assertEqual( await self.cog.upload_output("My awesome output"), - URLs.paste_service.format(key=key) + constants.URLs.paste_service.format(key=key) ) self.bot.http_session.post.assert_called_with( - URLs.paste_service.format(key="documents"), + constants.URLs.paste_service.format(key="documents"), data="My awesome output", raise_for_status=True ) @@ -302,6 +304,32 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(actual, None) ctx.message.clear_reactions.assert_called_once() + async def test_get_code(self): + """Should return 1st arg (or None) if eval cmd in message, otherwise return full content.""" + prefix = constants.Bot.prefix + subtests = ( + (self.cog.eval_command, f"{prefix}{self.cog.eval_command.name} print(1)", "print(1)"), + (self.cog.eval_command, f"{prefix}{self.cog.eval_command.name}", None), + (MagicMock(spec=commands.Command), f"{prefix}tags get foo"), + (None, "print(123)") + ) + + for command, content, *expected_code in subtests: + if not expected_code: + expected_code = content + else: + [expected_code] = expected_code + + with self.subTest(content=content, expected_code=expected_code): + self.bot.get_context.reset_mock() + self.bot.get_context.return_value = MockContext(command=command) + message = MockMessage(content=content) + + actual_code = await self.cog.get_code(message) + + self.bot.get_context.assert_awaited_once_with(message) + self.assertEqual(actual_code, expected_code) + def test_predicate_eval_message_edit(self): """Test the predicate_eval_message_edit function.""" msg0 = MockMessage(id=1, content='abc') -- cgit v1.2.3 From c3e9a290a93c978a4dfec3ab121a0e45147855c8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Mar 2020 14:08:34 -0700 Subject: Snekbox tests: use `get_code` in `test_continue_eval_does_continue` --- tests/bot/cogs/test_snekbox.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 1fad6904b..1dec0ccaf 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -1,7 +1,7 @@ import asyncio import logging import unittest -from unittest.mock import AsyncMock, MagicMock, Mock, call, patch +from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, patch from discord.ext import commands @@ -281,11 +281,14 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): """Test that the continue_eval function does continue if required conditions are met.""" ctx = MockContext(message=MockMessage(add_reaction=AsyncMock(), clear_reactions=AsyncMock())) response = MockMessage(delete=AsyncMock()) - new_msg = MockMessage(content='!e NewCode') + new_msg = MockMessage() self.bot.wait_for.side_effect = ((None, new_msg), None) + expected = "NewCode" + self.cog.get_code = create_autospec(self.cog.get_code, spec_set=True, return_value=expected) actual = await self.cog.continue_eval(ctx, response) - self.assertEqual(actual, 'NewCode') + self.cog.get_code.assert_awaited_once_with(new_msg) + 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), -- cgit v1.2.3 From ee7cfbfca1b23408d7cb3f603498347fcef00c86 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Thu, 26 Mar 2020 09:22:36 +0100 Subject: Change Alias warnings to info Stuff like "{name} tried to run {command}" and "{command} could not be found" was set as a warning, and so Sentry issues were being created for these. Our rule of thumb is that only actionable things should be warnings. Changed these to Info logs. --- bot/cogs/alias.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 0b800575f..9001e18f0 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -26,9 +26,9 @@ class Alias (Cog): log.debug(f"{cmd_name} was invoked through an alias") cmd = self.bot.get_command(cmd_name) if not cmd: - return log.warning(f'Did not find command "{cmd_name}" to invoke.') + return log.info(f'Did not find command "{cmd_name}" to invoke.') elif not await cmd.can_run(ctx): - return log.warning( + return log.info( f'{str(ctx.author)} tried to run the command "{cmd_name}"' ) -- cgit v1.2.3 From 4d33b9f863bb54e69b9530a1ee05e4068cafa9a6 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Thu, 26 Mar 2020 10:56:01 -0400 Subject: Initial pass on log severity reduction With the updated definition on logging levels, there are a few events that were issuing logs at too high of a level. This also includes some kaizening of existing log messages. --- bot/bot.py | 4 ++-- bot/cogs/alias.py | 2 +- bot/cogs/bot.py | 1 - bot/cogs/moderation/scheduler.py | 2 +- bot/cogs/moderation/superstarify.py | 4 ++-- bot/cogs/snekbox.py | 2 +- bot/converters.py | 2 +- 7 files changed, 8 insertions(+), 9 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 950ac6751..3e1b31342 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -77,7 +77,7 @@ class Bot(commands.Bot): # Its __del__ does send a warning but it doesn't always show up for some reason. if self._connector and not self._connector._closed: - log.warning( + log.info( "The previous connector was not closed; it will remain open and be overwritten" ) @@ -94,7 +94,7 @@ class Bot(commands.Bot): # Its __del__ does send a warning but it doesn't always show up for some reason. if self.http_session and not self.http_session.closed: - log.warning( + log.info( "The previous session was not closed; it will remain open and be overwritten" ) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 9001e18f0..55c7efe65 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -29,7 +29,7 @@ class Alias (Cog): return log.info(f'Did not find command "{cmd_name}" to invoke.') elif not await cmd.can_run(ctx): return log.info( - f'{str(ctx.author)} tried to run the command "{cmd_name}"' + f'{str(ctx.author)} tried to run the command "{cmd_name}" but lacks permission.' ) await ctx.invoke(cmd, *args, **kwargs) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index f17135877..e897b30ff 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -67,7 +67,6 @@ class BotCog(Cog, name="Bot"): icon_url=URLs.bot_avatar ) - log.info(f"{ctx.author} called !about. Returning information about the bot.") await ctx.send(embed=embed) @command(name='echo', aliases=('print',)) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index f0b6b2c48..917697be9 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -222,7 +222,7 @@ class InfractionScheduler(Scheduler): # If multiple active infractions were found, mark them as inactive in the database # and cancel their expiration tasks. if len(response) > 1: - log.warning( + log.info( f"Found more than one active {infr_type} infraction for user {user.id}; " "deactivating the extra active infractions too." ) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 893cb7f13..ca3dc4202 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -59,7 +59,7 @@ class Superstarify(InfractionScheduler, Cog): return # Nick change was triggered by this event. Ignore. log.info( - f"{after.display_name} is currently in superstar-prison. " + f"{after.display_name} ({after.id}) tried to escape superstar prison. " f"Changing the nick back to {before.display_name}." ) await after.edit( @@ -80,7 +80,7 @@ class Superstarify(InfractionScheduler, Cog): ) if not notified: - log.warning("Failed to DM user about why they cannot change their nickname.") + log.info("Failed to DM user about why they cannot change their nickname.") @Cog.listener() async def on_member_join(self, member: Member) -> None: diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index cff7c5786..b65b146ea 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -281,7 +281,7 @@ class Snekbox(Cog): code = await self.continue_eval(ctx, response) if not code: break - log.info(f"Re-evaluating message {ctx.message.id}") + log.info(f"Re-evaluating code from message {ctx.message.id}:\n{code}") def predicate_eval_message_edit(ctx: Context, old_msg: Message, new_msg: Message) -> bool: diff --git a/bot/converters.py b/bot/converters.py index 1945e1da3..98f4e33c8 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -323,7 +323,7 @@ class FetchedUser(UserConverter): except discord.HTTPException as e: # If the Discord error isn't `Unknown user`, return a proxy instead if e.code != 10013: - log.warning(f"Failed to fetch user, returning a proxy instead: status {e.status}") + log.info(f"Failed to fetch user, returning a proxy instead: status {e.status}") return proxy_user(arg) log.debug(f"Failed to fetch user {arg}: user does not exist.") -- cgit v1.2.3 From e88bb946fea8c4bc861f17772f8aca28f99be512 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 27 Mar 2020 11:22:34 -0700 Subject: Filtering: merge the word and token watch filters The only difference was the automatic addition of word boundaries. Otherwise, they shared a lot of code. The regex lists were kept separate in the config to retain the convenience of word boundaries automatically being added. * Rename filter to `watch_regex` * Expand spoilers for both words and tokens * Ignore URLs for both words and tokens --- bot/cogs/filtering.py | 56 +++++++++++++++++---------------------------------- bot/constants.py | 3 +-- config-default.yml | 3 +-- 3 files changed, 21 insertions(+), 41 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 6651d38e4..3f3dbb853 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -38,6 +38,7 @@ WORD_WATCHLIST_PATTERNS = [ TOKEN_WATCHLIST_PATTERNS = [ re.compile(fr'{expression}', flags=re.IGNORECASE) for expression in Filter.token_watchlist ] +WATCHLIST_PATTERNS = WORD_WATCHLIST_PATTERNS + TOKEN_WATCHLIST_PATTERNS def expand_spoilers(text: str) -> str: @@ -88,24 +89,18 @@ class Filtering(Cog): f"Your URL has been removed because it matched a blacklisted domain. {staff_mistake_str}" ) }, + "watch_regex": { + "enabled": Filter.watch_regex, + "function": self._has_watch_regex_match, + "type": "watchlist", + "content_only": True, + }, "watch_rich_embeds": { "enabled": Filter.watch_rich_embeds, "function": self._has_rich_embed, "type": "watchlist", "content_only": False, }, - "watch_words": { - "enabled": Filter.watch_words, - "function": self._has_watchlist_words, - "type": "watchlist", - "content_only": True, - }, - "watch_tokens": { - "enabled": Filter.watch_tokens, - "function": self._has_watchlist_tokens, - "type": "watchlist", - "content_only": True, - }, } @property @@ -191,8 +186,8 @@ class Filtering(Cog): else: channel_str = f"in {msg.channel.mention}" - # Word and match stats for watch_words and watch_tokens - if filter_name in ("watch_words", "watch_tokens"): + # Word and match stats for watch_regex + if filter_name == "watch_regex": surroundings = match.string[max(match.start() - 10, 0): match.end() + 10] message_content = ( f"**Match:** '{match[0]}'\n" @@ -248,37 +243,24 @@ class Filtering(Cog): break # We don't want multiple filters to trigger @staticmethod - async def _has_watchlist_words(text: str) -> Union[bool, re.Match]: + async def _has_watch_regex_match(text: str) -> Union[bool, re.Match]: """ - Returns True if the text contains one of the regular expressions from the word_watchlist in our filter config. + Return True if `text` matches any regex from `word_watchlist` or `token_watchlist` configs. - Only matches words with boundaries before and after the expression. + `word_watchlist`'s patterns are placed between word boundaries while `token_watchlist` is + matched as-is. Spoilers are expanded, if any, and URLs are ignored. """ if SPOILER_RE.search(text): text = expand_spoilers(text) - for regex_pattern in WORD_WATCHLIST_PATTERNS: - match = regex_pattern.search(text) - if match: - return match # match objects always have a boolean value of True - return False - - @staticmethod - async def _has_watchlist_tokens(text: str) -> Union[bool, re.Match]: - """ - Returns True if the text contains one of the regular expressions from the token_watchlist in our filter config. + # Make sure it's not a URL + if URL_RE.search(text): + return False - This will match the expression even if it does not have boundaries before and after. - """ - for regex_pattern in TOKEN_WATCHLIST_PATTERNS: - match = regex_pattern.search(text) + for pattern in WATCHLIST_PATTERNS: + match = pattern.search(text) if match: - - # Make sure it's not a URL - if not URL_RE.search(text): - return match # match objects always have a boolean value of True - - return False + return match @staticmethod async def _has_urls(text: str) -> bool: diff --git a/bot/constants.py b/bot/constants.py index 14f8dc094..549e69c8f 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -206,9 +206,8 @@ class Filter(metaclass=YAMLGetter): filter_zalgo: bool filter_invites: bool filter_domains: bool + watch_regex: bool watch_rich_embeds: bool - watch_words: bool - watch_tokens: bool # Notifications are not expected for "watchlist" type filters notify_user_zalgo: bool diff --git a/config-default.yml b/config-default.yml index 5788d1e12..ef0ed970f 100644 --- a/config-default.yml +++ b/config-default.yml @@ -248,9 +248,8 @@ filter: filter_zalgo: false filter_invites: true filter_domains: true + watch_regex: true watch_rich_embeds: true - watch_words: true - watch_tokens: true # Notify user on filter? # Notifications are not expected for "watchlist" type filters -- cgit v1.2.3 From aba5c321a5bbdaa9f47791b2aee456caa566cd98 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 10:54:03 +0200 Subject: (PEP Command): Hard-coded PEP 0 --- bot/cogs/utils.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 024141d62..db8f63ff4 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -40,6 +40,14 @@ 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! """ +PEP0_TITLE = "Index of Python Enhancement Proposals (PEPs)" +PEP0_INFO = { + "Status": "Active", + "Created": "13-Jul-2000", + "Type": "Informational" +} +PEP0_LINK = "https://www.python.org/dev/peps/" + class Utils(Cog): """A selection of utilities which don't have a clear category.""" @@ -59,6 +67,19 @@ class Utils(Cog): await ctx.invoke(self.bot.get_command("help"), "pep") return + # Handle PEP 0 directly due it's not available like other PEPs (use constants) + if pep_number == 0: + pep_embed = Embed( + title=f"**PEP 0 - {PEP0_TITLE}**", + description=f"[Link]({PEP0_LINK})" + ) + pep_embed.set_thumbnail(url="https://www.python.org/static/opengraph-icon-200x200.png") + for field, value in PEP0_INFO.items(): + pep_embed.add_field(name=field, value=value) + + await ctx.send(embed=pep_embed) + return + possible_extensions = ['.txt', '.rst'] found_pep = False for extension in possible_extensions: -- cgit v1.2.3 From f086fb2bc65f1031542d9bfae231a31ffeaa8a43 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 17:16:18 +0200 Subject: (Webhook Detection): Created cog. --- bot/cogs/webhook_remover.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 bot/cogs/webhook_remover.py diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py new file mode 100644 index 000000000..982359410 --- /dev/null +++ b/bot/cogs/webhook_remover.py @@ -0,0 +1,15 @@ +from discord.ext.commands import Cog + +from bot.bot import Bot + + +class WebhookRemover(Cog): + """Scan messages to detect Discord webhooks links.""" + + def __init__(self, bot: Bot): + self.bot = bot + + +def setup(bot: Bot) -> None: + """Load `WebhookRemover` cog.""" + bot.add_cog(WebhookRemover(bot)) -- cgit v1.2.3 From f4b5718225505b2b78e4cbf75c5599ab307455d2 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 17:19:40 +0200 Subject: (Webhook Detection): Added webhook match regex. --- bot/cogs/webhook_remover.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index 982359410..49cf94de7 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -1,7 +1,11 @@ +import re + from discord.ext.commands import Cog from bot.bot import Bot +WEBHOOK_URL_RE = re.compile(r"discordapp\.com/api/webhooks/\d+/\S+/?") + class WebhookRemover(Cog): """Scan messages to detect Discord webhooks links.""" -- cgit v1.2.3 From e5c41faf826e4a29fd21986fc828034372b18863 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 17:38:11 +0200 Subject: (Webhook Detection): Added cog loading to __main__.py, created `scan_message` helper function to detect Webhook URL. --- bot/__main__.py | 1 + bot/cogs/webhook_remover.py | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/bot/__main__.py b/bot/__main__.py index 3df477a6d..9e8b1bdce 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -64,6 +64,7 @@ bot.load_extension("bot.cogs.token_remover") bot.load_extension("bot.cogs.utils") bot.load_extension("bot.cogs.watchchannels") bot.load_extension("bot.cogs.wolfram") +bot.load_extension("bot.cogs.webhook_remover") # Apply `message_edited_at` patch if discord.py did not yet release a bug fix. if not hasattr(discord.message.Message, '_handle_edited_timestamp'): diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index 49cf94de7..a3025f19f 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -1,5 +1,6 @@ import re +from discord import Message from discord.ext.commands import Cog from bot.bot import Bot @@ -13,6 +14,14 @@ class WebhookRemover(Cog): def __init__(self, bot: Bot): self.bot = bot + async def scan_message(self, msg: Message) -> bool: + """Scan message content to detect Webhook URLs. Return `bool` about does this have webhook URL.""" + matches = WEBHOOK_URL_RE.search(msg.content) + if matches: + return True + else: + return False + def setup(bot: Bot) -> None: """Load `WebhookRemover` cog.""" -- cgit v1.2.3 From 7e34c5e62eeefe1f0b8a1bb7e03435b5d2998712 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 17:41:35 +0200 Subject: (Webhook Detection): Added `ModLog` fetching property. --- bot/cogs/webhook_remover.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index a3025f19f..54222b007 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -4,6 +4,7 @@ from discord import Message from discord.ext.commands import Cog from bot.bot import Bot +from bot.cogs.moderation.modlog import ModLog WEBHOOK_URL_RE = re.compile(r"discordapp\.com/api/webhooks/\d+/\S+/?") @@ -14,6 +15,11 @@ class WebhookRemover(Cog): def __init__(self, bot: Bot): self.bot = bot + @property + def mod_log(self) -> ModLog: + """Get current instance of `ModLog`.""" + return self.bot.get_cog("ModLog") + async def scan_message(self, msg: Message) -> bool: """Scan message content to detect Webhook URLs. Return `bool` about does this have webhook URL.""" matches = WEBHOOK_URL_RE.search(msg.content) -- cgit v1.2.3 From 3a9494da375a7aedf5b2c8554ae1cdd0170ba7f1 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 18:03:50 +0200 Subject: (Webhook Detection): Created `delete_and_respond` helper function to handle Webhook URLs. --- bot/cogs/webhook_remover.py | 43 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index 54222b007..a19f9c196 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -1,13 +1,24 @@ +import logging import re -from discord import Message +from discord import Colour, Message from discord.ext.commands import Cog from bot.bot import Bot from bot.cogs.moderation.modlog import ModLog +from bot.constants import Channels, Colours, Event, Icons WEBHOOK_URL_RE = re.compile(r"discordapp\.com/api/webhooks/\d+/\S+/?") +ALERT_MESSAGE_TEMPLATE = ( + "{user}, looks like you posted Discord Webhook URL to chat. " + "I removed this, but we **strongly** suggest to change this now " + "to prevent any spam abuse to channel. Please avoid doing this in future. " + "If you believe this was mistake, please let us know." +) + +log = logging.getLogger(__name__) + class WebhookRemover(Cog): """Scan messages to detect Discord webhooks links.""" @@ -21,13 +32,41 @@ class WebhookRemover(Cog): return self.bot.get_cog("ModLog") async def scan_message(self, msg: Message) -> bool: - """Scan message content to detect Webhook URLs. Return `bool` about does this have webhook URL.""" + """Scan message content to detect Webhook URLs. Return `bool` about does this have Discord webhook URL.""" matches = WEBHOOK_URL_RE.search(msg.content) if matches: return True else: return False + async def delete_and_respond(self, msg: Message, url: str) -> None: + """Delete message and show warning when message contains Discord Webhook URL.""" + # Create URL that will be sent to logs, remove token + parts = url.split("/") + parts[-1] = "xxx" + url = "/".join(parts) + + # Don't log this, due internal delete, not by user. Will make different entry. + self.mod_log.ignore(Event.message_delete, msg.id) + await msg.delete() + await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention)) + + message = ( + f"{msg.author} ({msg.author.id}) posted Discord Webhook URL " + f"to {msg.channel}. Webhook URL was {url}" + ) + log.debug(message) + + # Send entry to moderation alerts. + await self.mod_log.send_log_message( + icon_url=Icons.token_removed, + colour=Colour(Colours.soft_red), + title="Discord Webhook URL removed!", + text=message, + thumbnail=msg.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts + ) + def setup(bot: Bot) -> None: """Load `WebhookRemover` cog.""" -- cgit v1.2.3 From 3482471cd013bfc0102cc3b80c71e04dfc30349c Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 18:05:26 +0200 Subject: (Webhook Detection): Added URL returning to `scan_message` helper function. --- bot/cogs/webhook_remover.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index a19f9c196..d0d604bc7 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -1,5 +1,6 @@ import logging import re +import typing as t from discord import Colour, Message from discord.ext.commands import Cog @@ -31,13 +32,13 @@ class WebhookRemover(Cog): """Get current instance of `ModLog`.""" return self.bot.get_cog("ModLog") - async def scan_message(self, msg: Message) -> bool: + async def scan_message(self, msg: Message) -> t.Tuple[bool, t.Optional[str]]: """Scan message content to detect Webhook URLs. Return `bool` about does this have Discord webhook URL.""" matches = WEBHOOK_URL_RE.search(msg.content) if matches: - return True + return True, matches[0] else: - return False + return False, None async def delete_and_respond(self, msg: Message, url: str) -> None: """Delete message and show warning when message contains Discord Webhook URL.""" -- cgit v1.2.3 From 27efaf8414ec0211c0c1b3bba4b16a969eb01c0b Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 18:10:27 +0200 Subject: (Webhook Detection): Alert message formatting changes, added `on_message` listener. --- bot/cogs/webhook_remover.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index d0d604bc7..d6569a72b 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -53,8 +53,8 @@ class WebhookRemover(Cog): await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention)) message = ( - f"{msg.author} ({msg.author.id}) posted Discord Webhook URL " - f"to {msg.channel}. Webhook URL was {url}" + f"{msg.author} (`{msg.author.id}`) posted Discord Webhook URL " + f"to #{msg.channel}. Webhook URL was `{url}`" ) log.debug(message) @@ -68,6 +68,13 @@ class WebhookRemover(Cog): channel_id=Channels.mod_alerts ) + @Cog.listener() + async def on_message(self, msg: Message) -> None: + """Check is Discord Webhook URL in sent message.""" + is_url_in, url = await self.scan_message(msg) + if is_url_in: + await self.delete_and_respond(msg, url) + def setup(bot: Bot) -> None: """Load `WebhookRemover` cog.""" -- cgit v1.2.3 From 3f855231a3da94efe0e73448feaeb8f15d2799fc Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 18:57:03 +0200 Subject: (Webhook Detection): Added `on_message_edit` listener for Discord Webhooks detecting. --- bot/cogs/webhook_remover.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index d6569a72b..1f758f8e6 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -75,6 +75,13 @@ class WebhookRemover(Cog): if is_url_in: await self.delete_and_respond(msg, url) + @Cog.listener() + async def on_message_edit(self, before: Message, after: Message) -> None: + """Check is Discord Webhook URL in new message content when message changed.""" + is_url_in, url = await self.scan_message(after) + if is_url_in: + await self.delete_and_respond(after, url) + def setup(bot: Bot) -> None: """Load `WebhookRemover` cog.""" -- cgit v1.2.3 From b2b9353c8b775ffa687b8bafc875786815b173ce Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 19:28:38 +0200 Subject: (Webhook Detection): Fixed order of cog loading. --- bot/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/__main__.py b/bot/__main__.py index 9e8b1bdce..8c3ae02e3 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -63,8 +63,8 @@ bot.load_extension("bot.cogs.tags") bot.load_extension("bot.cogs.token_remover") bot.load_extension("bot.cogs.utils") bot.load_extension("bot.cogs.watchchannels") -bot.load_extension("bot.cogs.wolfram") bot.load_extension("bot.cogs.webhook_remover") +bot.load_extension("bot.cogs.wolfram") # Apply `message_edited_at` patch if discord.py did not yet release a bug fix. if not hasattr(discord.message.Message, '_handle_edited_timestamp'): -- cgit v1.2.3 From 6a410521025299f0e1795cc9c6d756ff48caf20d Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 19:31:58 +0200 Subject: (Webhook Detection): Call `on_message` instead repeating code. --- bot/cogs/webhook_remover.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index 1f758f8e6..5fb676045 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -78,9 +78,7 @@ class WebhookRemover(Cog): @Cog.listener() async def on_message_edit(self, before: Message, after: Message) -> None: """Check is Discord Webhook URL in new message content when message changed.""" - is_url_in, url = await self.scan_message(after) - if is_url_in: - await self.delete_and_respond(after, url) + await self.on_message(after) def setup(bot: Bot) -> None: -- cgit v1.2.3 From 81d2cdd39316b834b6b1de36b81260c4ab8489f5 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Sat, 28 Mar 2020 13:36:28 -0400 Subject: Logging severity pass from review --- bot/bot.py | 4 ++-- bot/cogs/sync/syncers.py | 2 +- bot/utils/messages.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 3e1b31342..950ac6751 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -77,7 +77,7 @@ class Bot(commands.Bot): # Its __del__ does send a warning but it doesn't always show up for some reason. if self._connector and not self._connector._closed: - log.info( + log.warning( "The previous connector was not closed; it will remain open and be overwritten" ) @@ -94,7 +94,7 @@ class Bot(commands.Bot): # Its __del__ does send a warning but it doesn't always show up for some reason. if self.http_session and not self.http_session.closed: - log.info( + log.warning( "The previous session was not closed; it will remain open and be overwritten" ) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index c7ce54d65..c9b3f0d40 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -131,7 +131,7 @@ class Syncer(abc.ABC): await message.edit(content=f':ok_hand: {mention}{self.name} sync will proceed.') return True else: - log.warning(f"The {self.name} syncer was aborted or timed out!") + log.trace(f"The {self.name} syncer was aborted or timed out!") await message.edit( content=f':warning: {mention}{self.name} sync aborted or timed out!' ) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index a36edc774..e969ee590 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -92,7 +92,7 @@ async def send_attachments( elif link_large: large.append(attachment) else: - log.warning(f"{failure_msg} because it's too large.") + log.info(f"{failure_msg} because it's too large.") except HTTPException as e: if link_large and e.status == 413: large.append(attachment) -- cgit v1.2.3 From 2544670fa38cea1f53147307b6b1e1134265a74f Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 19:42:31 +0200 Subject: (Webhook Detection): Added grouping to RegEx compilation, removed unnecessary function `scan_message`, moved this content to `on_message` event. --- bot/cogs/webhook_remover.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index 5fb676045..afa88ce89 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -1,6 +1,5 @@ import logging import re -import typing as t from discord import Colour, Message from discord.ext.commands import Cog @@ -9,7 +8,7 @@ from bot.bot import Bot from bot.cogs.moderation.modlog import ModLog from bot.constants import Channels, Colours, Event, Icons -WEBHOOK_URL_RE = re.compile(r"discordapp\.com/api/webhooks/\d+/\S+/?") +WEBHOOK_URL_RE = re.compile(r"(discordapp\.com/api/webhooks/)(\d+/)(\S+/?)") ALERT_MESSAGE_TEMPLATE = ( "{user}, looks like you posted Discord Webhook URL to chat. " @@ -32,14 +31,6 @@ class WebhookRemover(Cog): """Get current instance of `ModLog`.""" return self.bot.get_cog("ModLog") - async def scan_message(self, msg: Message) -> t.Tuple[bool, t.Optional[str]]: - """Scan message content to detect Webhook URLs. Return `bool` about does this have Discord webhook URL.""" - matches = WEBHOOK_URL_RE.search(msg.content) - if matches: - return True, matches[0] - else: - return False, None - async def delete_and_respond(self, msg: Message, url: str) -> None: """Delete message and show warning when message contains Discord Webhook URL.""" # Create URL that will be sent to logs, remove token @@ -71,9 +62,9 @@ class WebhookRemover(Cog): @Cog.listener() async def on_message(self, msg: Message) -> None: """Check is Discord Webhook URL in sent message.""" - is_url_in, url = await self.scan_message(msg) - if is_url_in: - await self.delete_and_respond(msg, url) + matches = WEBHOOK_URL_RE.search(msg.content) + if matches: + await self.delete_and_respond(msg, "".join(matches.groups()[:-1]) + "xxx") @Cog.listener() async def on_message_edit(self, before: Message, after: Message) -> None: -- cgit v1.2.3 From 3e342882a927298cea919c33678cd39c4a71c67e Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 19:44:25 +0200 Subject: (Webhook Detection): Removed unnecessary URL hiding in `delete_and_respond`. --- bot/cogs/webhook_remover.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index afa88ce89..9f6243b3c 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -33,11 +33,6 @@ class WebhookRemover(Cog): async def delete_and_respond(self, msg: Message, url: str) -> None: """Delete message and show warning when message contains Discord Webhook URL.""" - # Create URL that will be sent to logs, remove token - parts = url.split("/") - parts[-1] = "xxx" - url = "/".join(parts) - # Don't log this, due internal delete, not by user. Will make different entry. self.mod_log.ignore(Event.message_delete, msg.id) await msg.delete() -- cgit v1.2.3 From 2532c55239a1563b34ed475bffa330e1670de6e0 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 19:45:51 +0200 Subject: (Webhook Detection): Fixed docstrings. --- bot/cogs/webhook_remover.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index 9f6243b3c..cbece321d 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -56,14 +56,14 @@ class WebhookRemover(Cog): @Cog.listener() async def on_message(self, msg: Message) -> None: - """Check is Discord Webhook URL in sent message.""" + """Check if a Discord webhook URL is in `message`.""" matches = WEBHOOK_URL_RE.search(msg.content) if matches: await self.delete_and_respond(msg, "".join(matches.groups()[:-1]) + "xxx") @Cog.listener() async def on_message_edit(self, before: Message, after: Message) -> None: - """Check is Discord Webhook URL in new message content when message changed.""" + """Check if a Discord webhook URL is in the edited message `after`.""" await self.on_message(after) -- cgit v1.2.3 From bf20911cb7f9310450293e93babff9bea8a177f9 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 20:24:28 +0200 Subject: (Webhook Detection): Renamed `url` variable to `redacted_url` to avoid confusion in `delete_and_respond` function. --- bot/cogs/webhook_remover.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index cbece321d..b4606eb59 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -31,7 +31,7 @@ class WebhookRemover(Cog): """Get current instance of `ModLog`.""" return self.bot.get_cog("ModLog") - async def delete_and_respond(self, msg: Message, url: str) -> None: + async def delete_and_respond(self, msg: Message, redacted_url: str) -> None: """Delete message and show warning when message contains Discord Webhook URL.""" # Don't log this, due internal delete, not by user. Will make different entry. self.mod_log.ignore(Event.message_delete, msg.id) @@ -40,7 +40,7 @@ class WebhookRemover(Cog): message = ( f"{msg.author} (`{msg.author.id}`) posted Discord Webhook URL " - f"to #{msg.channel}. Webhook URL was `{url}`" + f"to #{msg.channel}. Webhook URL was `{redacted_url}`" ) log.debug(message) -- cgit v1.2.3 From e955b83784c91c0144334b744f1d5e139a1d957f Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 20:28:24 +0200 Subject: (PEP Command): Fixed comment of explanation of PEP 0 different processing. --- bot/cogs/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index db8f63ff4..f35ff0f03 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -67,7 +67,8 @@ class Utils(Cog): await ctx.invoke(self.bot.get_command("help"), "pep") return - # Handle PEP 0 directly due it's not available like other PEPs (use constants) + # Handle PEP 0 directly due it's static constant in PEPs GitHub repo in Python file, not .rst or .txt so it + # can't be accessed like other PEPs. if pep_number == 0: pep_embed = Embed( title=f"**PEP 0 - {PEP0_TITLE}**", -- cgit v1.2.3 From 608f5c5edec5804ba6dd546d25f63ce13b34b948 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 28 Mar 2020 12:54:26 -0700 Subject: Use debug log level instead of warning in `post_user` --- bot/cogs/moderation/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 5052b9048..3598f3b1f 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -38,7 +38,7 @@ async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]: log.trace(f"Attempting to add user {user.id} to the database.") if not isinstance(user, (discord.Member, discord.User)): - log.warning("The user being added to the DB is not a Member or User object.") + log.debug("The user being added to the DB is not a Member or User object.") payload = { 'avatar_hash': getattr(user, 'avatar', 0), -- cgit v1.2.3 From bf18e1ca460427e5a973be0b15c51e1c7b5b6e60 Mon Sep 17 00:00:00 2001 From: Karlis S <45097959+ks129@users.noreply.github.com> Date: Sat, 28 Mar 2020 22:17:07 +0200 Subject: (Webhook Detection): Fixed grouping of regex, alert message content, docstrings, string formatting and URL hiding to show in logs. Co-Authored-By: Mark --- bot/cogs/webhook_remover.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index b4606eb59..49692113d 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -8,13 +8,13 @@ from bot.bot import Bot from bot.cogs.moderation.modlog import ModLog from bot.constants import Channels, Colours, Event, Icons -WEBHOOK_URL_RE = re.compile(r"(discordapp\.com/api/webhooks/)(\d+/)(\S+/?)") +WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discordapp\.com/api/webhooks/\d+/)\S+/?", re.I) ALERT_MESSAGE_TEMPLATE = ( - "{user}, looks like you posted Discord Webhook URL to chat. " - "I removed this, but we **strongly** suggest to change this now " - "to prevent any spam abuse to channel. Please avoid doing this in future. " - "If you believe this was mistake, please let us know." + "{user}, looks like you posted a Discord webhook URL. Therefore, your " + "message has been removed. Your webhook may have been **compromised** so " + "please re-create the webhook **immediately**. If you believe this was " + "mistake, please let us know." ) log = logging.getLogger(__name__) @@ -32,14 +32,14 @@ class WebhookRemover(Cog): return self.bot.get_cog("ModLog") async def delete_and_respond(self, msg: Message, redacted_url: str) -> None: - """Delete message and show warning when message contains Discord Webhook URL.""" + """Delete `msg` and send a warning that it contained the Discord webhook `redacted_url`.""" # Don't log this, due internal delete, not by user. Will make different entry. self.mod_log.ignore(Event.message_delete, msg.id) await msg.delete() await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention)) message = ( - f"{msg.author} (`{msg.author.id}`) posted Discord Webhook URL " + f"{msg.author} (`{msg.author.id}`) posted a Discord webhook URL " f"to #{msg.channel}. Webhook URL was `{redacted_url}`" ) log.debug(message) @@ -48,7 +48,7 @@ class WebhookRemover(Cog): await self.mod_log.send_log_message( icon_url=Icons.token_removed, colour=Colour(Colours.soft_red), - title="Discord Webhook URL removed!", + title="Discord webhook URL removed!", text=message, thumbnail=msg.author.avatar_url_as(static_format="png"), channel_id=Channels.mod_alerts @@ -59,7 +59,7 @@ class WebhookRemover(Cog): """Check if a Discord webhook URL is in `message`.""" matches = WEBHOOK_URL_RE.search(msg.content) if matches: - await self.delete_and_respond(msg, "".join(matches.groups()[:-1]) + "xxx") + await self.delete_and_respond(msg, matches[1] + "xxx") @Cog.listener() async def on_message_edit(self, before: Message, after: Message) -> None: -- cgit v1.2.3 From cc153e052b765ddd8ee1494ad3eea2a552d9459c Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Sat, 28 Mar 2020 16:26:01 -0400 Subject: Increase syncer logging level --- bot/cogs/sync/syncers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index c9b3f0d40..003bf3727 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -131,7 +131,7 @@ class Syncer(abc.ABC): await message.edit(content=f':ok_hand: {mention}{self.name} sync will proceed.') return True else: - log.trace(f"The {self.name} syncer was aborted or timed out!") + log.info(f"The {self.name} syncer was aborted or timed out!") await message.edit( content=f':warning: {mention}{self.name} sync aborted or timed out!' ) -- cgit v1.2.3 From 317b5db5585ae72adf112508165fc6e161792948 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sun, 29 Mar 2020 08:55:22 +0300 Subject: (PEP Command): Moved icon URL to constant instead hard-coded string. --- bot/cogs/utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index f35ff0f03..d15edd0a0 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -48,6 +48,8 @@ PEP0_INFO = { } PEP0_LINK = "https://www.python.org/dev/peps/" +ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png" + class Utils(Cog): """A selection of utilities which don't have a clear category.""" @@ -74,7 +76,7 @@ class Utils(Cog): title=f"**PEP 0 - {PEP0_TITLE}**", description=f"[Link]({PEP0_LINK})" ) - pep_embed.set_thumbnail(url="https://www.python.org/static/opengraph-icon-200x200.png") + pep_embed.set_thumbnail(url=ICON_URL) for field, value in PEP0_INFO.items(): pep_embed.add_field(name=field, value=value) @@ -104,7 +106,7 @@ class Utils(Cog): description=f"[Link]({self.base_pep_url}{pep_number:04})", ) - pep_embed.set_thumbnail(url="https://www.python.org/static/opengraph-icon-200x200.png") + pep_embed.set_thumbnail(url=ICON_URL) # Add the interesting information fields_to_check = ("Status", "Python-Version", "Created", "Type") -- cgit v1.2.3 From 3e819043ce0538682b4512382974b641dc6872c0 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sun, 29 Mar 2020 09:03:27 +0300 Subject: (PEP Command): Moved PEP 0 information to hard-coded strings from constants, moved PEP 0 sending to function. --- bot/cogs/utils.py | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index d15edd0a0..3cd259b35 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -40,14 +40,6 @@ 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! """ -PEP0_TITLE = "Index of Python Enhancement Proposals (PEPs)" -PEP0_INFO = { - "Status": "Active", - "Created": "13-Jul-2000", - "Type": "Informational" -} -PEP0_LINK = "https://www.python.org/dev/peps/" - ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png" @@ -72,16 +64,7 @@ class Utils(Cog): # Handle PEP 0 directly due it's static constant in PEPs GitHub repo in Python file, not .rst or .txt so it # can't be accessed like other PEPs. if pep_number == 0: - pep_embed = Embed( - title=f"**PEP 0 - {PEP0_TITLE}**", - description=f"[Link]({PEP0_LINK})" - ) - pep_embed.set_thumbnail(url=ICON_URL) - for field, value in PEP0_INFO.items(): - pep_embed.add_field(name=field, value=value) - - await ctx.send(embed=pep_embed) - return + return await self.send_pep_zero(ctx) possible_extensions = ['.txt', '.rst'] found_pep = False @@ -302,6 +285,19 @@ class Utils(Cog): for reaction in options: await message.add_reaction(reaction) + async def send_pep_zero(self, ctx: Context) -> None: + """Send information about PEP 0.""" + pep_embed = Embed( + title=f"**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", + description=f"[Link](https://www.python.org/dev/peps/)" + ) + pep_embed.set_thumbnail(url=ICON_URL) + pep_embed.add_field(name="Status", value="Active") + pep_embed.add_field(name="Created", value="13-Jul-2000") + pep_embed.add_field(name="Type", value="Informational") + + await ctx.send(embed=pep_embed) + def setup(bot: Bot) -> None: """Load the Utils cog.""" -- cgit v1.2.3 From 8bebc1e68dba2252a0a7abee456bf02512c1e60e Mon Sep 17 00:00:00 2001 From: ks123 Date: Sun, 29 Mar 2020 09:06:00 +0300 Subject: (PEP Command): Fixed comment about PEP 0 separately handling. --- bot/cogs/utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 3cd259b35..f0b1172e3 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -61,8 +61,7 @@ class Utils(Cog): await ctx.invoke(self.bot.get_command("help"), "pep") return - # Handle PEP 0 directly due it's static constant in PEPs GitHub repo in Python file, not .rst or .txt so it - # can't be accessed like other PEPs. + # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. if pep_number == 0: return await self.send_pep_zero(ctx) -- cgit v1.2.3 From 5d24f9a487a2d5c731a865e3ed808db6157951ea Mon Sep 17 00:00:00 2001 From: Karlis S <45097959+ks129@users.noreply.github.com> Date: Sun, 29 Mar 2020 19:27:34 +0300 Subject: (Infraction Edit): Don't let change expiration when infraction already expired. --- bot/cogs/moderation/management.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 35448f682..531bb1743 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -100,7 +100,9 @@ class ModManagement(commands.Cog): confirm_messages = [] log_text = "" - if isinstance(duration, str): + if duration is not None and not old_infraction['active']: + confirm_messages.append("expiry unchanged (infraction already expired)") + elif isinstance(duration, str): request_data['expires_at'] = None confirm_messages.append("marked as permanent") elif duration is not None: -- cgit v1.2.3 From 28e9c74a57dbfac8049c90e7d128a041cbadedde Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 29 Mar 2020 17:00:45 -0700 Subject: HelpChannels: fix alphabetical sorting of dormant channels When a channel is moved, all channels below have their positions incremented by 1. This threw off the previous implementation which relied on position numbers being static. Co-authored-by: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> --- bot/cogs/help_channels.py | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 69af085ee..10b17cdb8 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -1,4 +1,5 @@ import asyncio +import bisect import inspect import itertools import json @@ -218,22 +219,30 @@ class HelpChannels(Scheduler, commands.Cog): return channel - def get_alphabetical_position(self, channel: discord.TextChannel) -> t.Optional[int]: - """ - Return the position to move `channel` to so alphabetic order is maintained. - - If the channel does not have a valid name with a chemical element, return None. - """ + @staticmethod + def get_position(channel: discord.TextChannel, destination: discord.CategoryChannel) -> int: + """Return alphabetical position for `channel` if moved to `destination`.""" log.trace(f"Getting alphabetical position for #{channel} ({channel.id}).") - try: - position = self.name_positions[channel.name] - except KeyError: - log.warning(f"Channel #{channel} ({channel.id}) doesn't have a valid name.") - position = None + # If the destination category is empty, use the first position + if not destination.channels: + position = 1 + else: + # Make a sorted list of channel names for bisect. + channel_names = [c.name for c in destination.channels] + + # Get location which would maintain sorted order if channel was inserted into the list. + rank = bisect.bisect(channel_names, channel.name) + + if rank == len(destination.channels): + # Channel should be moved to the end of the category. + position = destination.channels[-1].position + 1 + else: + # Channel should be moved to the position of its alphabetical successor. + position = destination.channels[rank].position log.trace( - f"Position of #{channel} ({channel.id}) in Dormant will be {position} " + f"Position of #{channel} ({channel.id}) in {destination.name} will be {position} " f"(was {channel.position})." ) @@ -438,7 +447,7 @@ class HelpChannels(Scheduler, commands.Cog): category=self.dormant_category, sync_permissions=True, topic=DORMANT_TOPIC, - position=self.get_alphabetical_position(channel), + position=self.get_position(channel, self.dormant_category), ) log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.") -- cgit v1.2.3 From 0c6a5a1da30fb22f3e10400fe99bc90d77926e5d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 29 Mar 2020 17:02:20 -0700 Subject: HelpChannels: remove positions from element names There is no longer a reliance on static alphabetical position numbers. --- bot/cogs/help_channels.py | 12 +-- bot/resources/elements.json | 240 ++++++++++++++++++++++---------------------- 2 files changed, 125 insertions(+), 127 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 10b17cdb8..3014cffa8 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -1,7 +1,6 @@ import asyncio import bisect import inspect -import itertools import json import logging import random @@ -259,9 +258,9 @@ class HelpChannels(Scheduler, commands.Cog): yield channel @staticmethod - def get_names() -> t.Dict[str, int]: + def get_names() -> t.List[str]: """ - Return a truncated dict of prefixed element names and their alphabetical indices. + Return a truncated list of prefixed element names. The amount of names if configured with `HelpChannels.max_total_channels`. The prefix is configured with `HelpChannels.name_prefix`. @@ -274,11 +273,10 @@ class HelpChannels(Scheduler, commands.Cog): with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file: all_names = json.load(elements_file) - names = itertools.islice(all_names.items(), count) if prefix: - names = ((prefix + name, pos) for name, pos in names) - - return dict(names) + return [prefix + name for name in all_names[:count]] + else: + return all_names[:count] def get_used_names(self) -> t.Set[str]: """Return channels names which are already being used.""" diff --git a/bot/resources/elements.json b/bot/resources/elements.json index 6ea4964aa..2dc9b6fd6 100644 --- a/bot/resources/elements.json +++ b/bot/resources/elements.json @@ -1,120 +1,120 @@ -{ - "hydrogen": 44, - "helium": 42, - "lithium": 53, - "beryllium": 9, - "boron": 12, - "carbon": 18, - "nitrogen": 69, - "oxygen": 73, - "fluorine": 34, - "neon": 64, - "sodium": 97, - "magnesium": 56, - "aluminium": 1, - "silicon": 95, - "phosphorus": 75, - "sulfur": 99, - "chlorine": 20, - "argon": 4, - "potassium": 79, - "calcium": 16, - "scandium": 92, - "titanium": 109, - "vanadium": 112, - "chromium": 21, - "manganese": 57, - "iron": 48, - "cobalt": 22, - "nickel": 66, - "copper": 24, - "zinc": 116, - "gallium": 37, - "germanium": 38, - "arsenic": 5, - "selenium": 94, - "bromine": 13, - "krypton": 49, - "rubidium": 88, - "strontium": 98, - "yttrium": 115, - "zirconium": 117, - "niobium": 68, - "molybdenum": 61, - "technetium": 101, - "ruthenium": 89, - "rhodium": 86, - "palladium": 74, - "silver": 96, - "cadmium": 14, - "indium": 45, - "tin": 108, - "antimony": 3, - "tellurium": 102, - "iodine": 46, - "xenon": 113, - "caesium": 15, - "barium": 7, - "lanthanum": 50, - "cerium": 19, - "praseodymium": 80, - "neodymium": 63, - "promethium": 81, - "samarium": 91, - "europium": 31, - "gadolinium": 36, - "terbium": 104, - "dysprosium": 28, - "holmium": 43, - "erbium": 30, - "thulium": 107, - "ytterbium": 114, - "lutetium": 55, - "hafnium": 40, - "tantalum": 100, - "tungsten": 110, - "rhenium": 85, - "osmium": 72, - "iridium": 47, - "platinum": 76, - "gold": 39, - "mercury": 60, - "thallium": 105, - "lead": 52, - "bismuth": 10, - "polonium": 78, - "astatine": 6, - "radon": 84, - "francium": 35, - "radium": 83, - "actinium": 0, - "thorium": 106, - "protactinium": 82, - "uranium": 111, - "neptunium": 65, - "plutonium": 77, - "americium": 2, - "curium": 25, - "berkelium": 8, - "californium": 17, - "einsteinium": 29, - "fermium": 32, - "mendelevium": 59, - "nobelium": 70, - "lawrencium": 51, - "rutherfordium": 90, - "dubnium": 27, - "seaborgium": 93, - "bohrium": 11, - "hassium": 41, - "meitnerium": 58, - "darmstadtium": 26, - "roentgenium": 87, - "copernicium": 23, - "nihonium": 67, - "flerovium": 33, - "moscovium": 62, - "livermorium": 54, - "tennessine": 103, - "oganesson": 71 -} +[ + "hydrogen", + "helium", + "lithium", + "beryllium", + "boron", + "carbon", + "nitrogen", + "oxygen", + "fluorine", + "neon", + "sodium", + "magnesium", + "aluminium", + "silicon", + "phosphorus", + "sulfur", + "chlorine", + "argon", + "potassium", + "calcium", + "scandium", + "titanium", + "vanadium", + "chromium", + "manganese", + "iron", + "cobalt", + "nickel", + "copper", + "zinc", + "gallium", + "germanium", + "arsenic", + "selenium", + "bromine", + "krypton", + "rubidium", + "strontium", + "yttrium", + "zirconium", + "niobium", + "molybdenum", + "technetium", + "ruthenium", + "rhodium", + "palladium", + "silver", + "cadmium", + "indium", + "tin", + "antimony", + "tellurium", + "iodine", + "xenon", + "caesium", + "barium", + "lanthanum", + "cerium", + "praseodymium", + "neodymium", + "promethium", + "samarium", + "europium", + "gadolinium", + "terbium", + "dysprosium", + "holmium", + "erbium", + "thulium", + "ytterbium", + "lutetium", + "hafnium", + "tantalum", + "tungsten", + "rhenium", + "osmium", + "iridium", + "platinum", + "gold", + "mercury", + "thallium", + "lead", + "bismuth", + "polonium", + "astatine", + "radon", + "francium", + "radium", + "actinium", + "thorium", + "protactinium", + "uranium", + "neptunium", + "plutonium", + "americium", + "curium", + "berkelium", + "californium", + "einsteinium", + "fermium", + "mendelevium", + "nobelium", + "lawrencium", + "rutherfordium", + "dubnium", + "seaborgium", + "bohrium", + "hassium", + "meitnerium", + "darmstadtium", + "roentgenium", + "copernicium", + "nihonium", + "flerovium", + "moscovium", + "livermorium", + "tennessine", + "oganesson" +] -- cgit v1.2.3 From 1509d3b1c39fac825d5e5a09fd0caf780db7c91c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 29 Mar 2020 17:13:02 -0700 Subject: BotCog: fix AttributeError getting a category for a DMChannel --- bot/cogs/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 267892cc3..f0ca2b175 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -225,7 +225,7 @@ class BotCog(Cog, name="Bot"): properly formatted Python syntax highlighting codeblocks. """ is_help_channel = ( - msg.channel.category + getattr(msg.channel, "category", None) and msg.channel.category.id in (Categories.help_available, Categories.help_in_use) ) parse_codeblock = ( -- cgit v1.2.3 From 00c9f092a0c056b21e012fdadd807d0410a3ca09 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 29 Mar 2020 17:22:53 -0700 Subject: HelpChannels: fix typo in docstring --- 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 3014cffa8..273c5d98c 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -262,7 +262,7 @@ class HelpChannels(Scheduler, commands.Cog): """ Return a truncated list of prefixed element names. - The amount of names if configured with `HelpChannels.max_total_channels`. + The amount of names is configured with `HelpChannels.max_total_channels`. The prefix is configured with `HelpChannels.name_prefix`. """ count = constants.HelpChannels.max_total_channels -- cgit v1.2.3 From f9fa3e6a67a196c9b529c9a8b8b68bcd89f0dcec Mon Sep 17 00:00:00 2001 From: ks123 Date: Mon, 30 Mar 2020 09:29:50 +0300 Subject: (Tags): Added helper function `handle_trashcan_react` for tag response deletion handling. --- bot/cogs/tags.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 539105017..293fa36f6 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -1,14 +1,16 @@ import logging import re import time +from asyncio import TimeoutError from pathlib import Path from typing import Callable, Dict, Iterable, List, Optional -from discord import Colour, Embed +from discord import Colour, Embed, Message, Reaction, User from discord.ext.commands import Cog, Context, group from bot import constants from bot.bot import Bot +from bot.constants import Emojis from bot.converters import TagNameConverter from bot.pagination import LinePaginator @@ -139,6 +141,24 @@ class Tags(Cog): max_lines=15 ) + async def handle_trashcan_react(self, ctx: Context, msg: Message) -> None: + """Add `trashcan` emoji to Tag and handle deletion when user react to it.""" + await msg.add_reaction(Emojis.trashcan) + + def check_trashcan(reaction: Reaction, user: User) -> bool: + return ( + reaction.emoji == Emojis.trashcan + and user.id == ctx.author.id + and reaction.message == msg + ) + try: + await self.bot.wait_for("reaction_add", timeout=60.0, check=check_trashcan) + except TimeoutError: + await msg.remove_reaction(Emojis.trashcan, msg.author) + else: + await ctx.message.delete() + await msg.delete() + @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.""" -- cgit v1.2.3 From b37f221b9b68efed48409a64a802ea0df009627f Mon Sep 17 00:00:00 2001 From: ks123 Date: Mon, 30 Mar 2020 09:36:45 +0300 Subject: (Tags): Added trashcan handling to `!tags get` command. --- bot/cogs/tags.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 293fa36f6..5dbb75c73 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -225,12 +225,14 @@ class Tags(Cog): "time": time.time(), "channel": ctx.channel.id } - await ctx.send(embed=Embed.from_dict(tag['embed'])) + msg = await ctx.send(embed=Embed.from_dict(tag['embed'])) + await self.handle_trashcan_react(ctx, msg) elif founds and len(tag_name) >= 3: - await ctx.send(embed=Embed( + msg = await ctx.send(embed=Embed( title='Did you mean ...', description='\n'.join(tag['title'] for tag in founds[:10]) )) + await self.handle_trashcan_react(ctx, msg) else: tags = self._cache.values() -- cgit v1.2.3 From 307aacbf1b7304ebb52d5193f19b5a12623cdbfd Mon Sep 17 00:00:00 2001 From: ks123 Date: Mon, 30 Mar 2020 09:44:13 +0300 Subject: (Tags): Fixed trashcan handling check. --- bot/cogs/tags.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 5dbb75c73..3f9647eb5 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -148,8 +148,8 @@ class Tags(Cog): def check_trashcan(reaction: Reaction, user: User) -> bool: return ( reaction.emoji == Emojis.trashcan - and user.id == ctx.author.id - and reaction.message == msg + and user == ctx.author + and reaction.message.id == msg.id ) try: await self.bot.wait_for("reaction_add", timeout=60.0, check=check_trashcan) -- cgit v1.2.3 From 582ddbb1ca8bab2cb883781911f5f35962330995 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 30 Mar 2020 13:36:34 +0200 Subject: Set unsilence permissions to inherit instead of true The "unsilence" action of the silence/hush command used `send_messages=True` when unsilencing a hushed channel. This had the side effect of also enabling send messages permissions for those with the Muted rule, as an explicit True permission apparently overwrites an explicit False permission, even if the latter was set for a higher top-role. The solution is to revert back to the `Inherit` permission by assigning `None`. This is what we normally use when Developers are allowed to send messages to a channel. --- bot/cogs/moderation/silence.py | 2 +- tests/bot/cogs/moderation/test_silence.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index a1446089e..1ef3967a9 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -138,7 +138,7 @@ class Silence(commands.Cog): """ current_overwrite = channel.overwrites_for(self._verified_role) if current_overwrite.send_messages is False: - await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=True)) + await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=None)) log.info(f"Unsilenced channel #{channel} ({channel.id}).") self.notifier.remove_channel(channel) self.muted_channels.discard(channel) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 44682a1bd..3fd149f04 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -194,7 +194,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) self.assertTrue(await self.cog._unsilence(channel)) channel.set_permissions.assert_called_once() - self.assertTrue(channel.set_permissions.call_args.kwargs['send_messages']) + self.assertIsNone(channel.set_permissions.call_args.kwargs['send_messages']) @mock.patch.object(Silence, "notifier", create=True) async def test_unsilence_private_removed_notifier(self, notifier): -- cgit v1.2.3 From c289111feee7be3a82772bffe0d253dde77b3f01 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 30 Mar 2020 09:01:31 -0700 Subject: HelpChannels: fix typos in docstrings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Leon Sandøy --- bot/cogs/help_channels.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 273c5d98c..984a11f61 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -279,7 +279,7 @@ class HelpChannels(Scheduler, commands.Cog): return all_names[:count] def get_used_names(self) -> t.Set[str]: - """Return channels names which are already being used.""" + """Return channel names which are already being used.""" log.trace("Getting channel names which are already being used.") names = set() @@ -371,7 +371,7 @@ class HelpChannels(Scheduler, commands.Cog): # Prevent the command from being used until ready. # The ready event wasn't used because channels could change categories between the time # the command is invoked and the cog is ready (e.g. if move_idle_channel wasn't called yet). - # This may confused users. So would potentially long delays for the cog to become ready. + # This may confuse users. So would potentially long delays for the cog to become ready. self.dormant_command.enabled = True await self.init_available() -- cgit v1.2.3 From 58fad6541fbff07c32883dcd2b4d046b1ef9d9b0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 30 Mar 2020 09:05:30 -0700 Subject: HelpChannels: use constant names instead of default values in docstring --- bot/cogs/help_channels.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 984a11f61..ff8d31ded 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -78,20 +78,19 @@ class HelpChannels(Scheduler, commands.Cog): Available Category * Contains channels which are ready to be occupied by someone who needs help - * Will always contain 2 channels; refilled automatically from the pool of dormant channels + * Will always contain `constants.HelpChannels.max_available` channels; refilled automatically + from the pool of dormant channels * Prioritise using the channels which have been dormant for the longest amount of time * If there are no more dormant channels, the bot will automatically create a new one - * Configurable with `constants.HelpChannels.max_available` * If there are no dormant channels to move, helpers will be notified (see `notify()`) * When a channel becomes available, the dormant embed will be edited to show `AVAILABLE_MSG` In Use Category * Contains all channels which are occupied by someone needing help - * Channel moves to dormant category after 45 minutes of being idle - * Configurable with `constants.HelpChannels.idle_minutes` - * Helpers+ command can prematurely mark a channel as dormant - * Configurable with `constants.HelpChannels.cmd_whitelist` + * Channel moves to dormant category after `constants.HelpChannels.idle_minutes` of being idle + * Command can prematurely mark a channel as dormant + * Allowed roles for the command are configurable with `constants.HelpChannels.cmd_whitelist` * When a channel becomes dormant, an embed with `DORMANT_MSG` will be sent Dormant Category -- cgit v1.2.3 From d37a0a16e391ad14f2569a245ee205223f8f26dc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 30 Mar 2020 09:41:00 -0700 Subject: ModLog: ignore update channel events for help channels The edit causes two channel update events to dispatch simultaneously: one for the channel topic changing and one for the category changing. The ModLog cog currently doesn't support ignoring multiple events of the same type for the same channel. Therefore, the ignore was hard coded rather than using the typical ignore mechanism. This is intended to be a temporary solution; it should be removed once the ModLog is changed to support this situation. --- bot/cogs/moderation/modlog.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index c63b4bab9..beef7a8ef 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -15,7 +15,7 @@ from discord.ext.commands import Cog, Context from discord.utils import escape_markdown from bot.bot import Bot -from bot.constants import Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs +from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs from bot.utils.time import humanize_delta log = logging.getLogger(__name__) @@ -188,6 +188,12 @@ class ModLog(Cog, name="ModLog"): self._ignored[Event.guild_channel_update].remove(before.id) return + # Two channel updates are sent for a single edit: 1 for topic and 1 for category change. + # TODO: remove once support is added for ignoring multiple occurrences for the same channel. + help_categories = (Categories.help_available, Categories.help_dormant, Categories.help_in_use) + if after.category and after.category.id in help_categories: + return + diff = DeepDiff(before, after) changes = [] done = [] -- cgit v1.2.3 From 740bf3e81aba605cb2b4690e5bb25dcba85cd174 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 30 Mar 2020 10:11:24 -0700 Subject: HelpChannels: set to enabled by default --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 12f69deca..89f470e19 100644 --- a/config-default.yml +++ b/config-default.yml @@ -505,7 +505,7 @@ mention: reset_delay: 5 help_channels: - enable: false + enable: true # Minimum interval before allowing a certain user to claim a new help channel claim_minutes: 15 -- cgit v1.2.3 From 6f273e96714c6de4738ec5ed2026e17cd3668594 Mon Sep 17 00:00:00 2001 From: ks123 Date: Tue, 31 Mar 2020 10:52:45 +0300 Subject: (Tags): Modified helper function `handle_trashcan_react` to `send_embed_with_trashcan`, applied to docstring and to command. --- bot/cogs/tags.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 3f9647eb5..3729b4511 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -5,7 +5,7 @@ from asyncio import TimeoutError from pathlib import Path from typing import Callable, Dict, Iterable, List, Optional -from discord import Colour, Embed, Message, Reaction, User +from discord import Colour, Embed, Reaction, User from discord.ext.commands import Cog, Context, group from bot import constants @@ -141,8 +141,9 @@ class Tags(Cog): max_lines=15 ) - async def handle_trashcan_react(self, ctx: Context, msg: Message) -> None: - """Add `trashcan` emoji to Tag and handle deletion when user react to it.""" + async def send_embed_with_trashcan(self, ctx: Context, embed: Embed) -> None: + """Send embed and handle it's and command message deletion with `trashcan` emoji.""" + msg = await ctx.send(embed=embed) await msg.add_reaction(Emojis.trashcan) def check_trashcan(reaction: Reaction, user: User) -> bool: @@ -225,14 +226,12 @@ class Tags(Cog): "time": time.time(), "channel": ctx.channel.id } - msg = await ctx.send(embed=Embed.from_dict(tag['embed'])) - await self.handle_trashcan_react(ctx, msg) + await self.send_embed_with_trashcan(ctx, Embed.from_dict(tag['embed'])) elif founds and len(tag_name) >= 3: - msg = await ctx.send(embed=Embed( + await self.send_embed_with_trashcan(ctx, Embed( title='Did you mean ...', description='\n'.join(tag['title'] for tag in founds[:10]) )) - await self.handle_trashcan_react(ctx, msg) else: tags = self._cache.values() -- cgit v1.2.3 From e28a580200243669b1a9219b9e9d19b7f5a503af Mon Sep 17 00:00:00 2001 From: ks123 Date: Tue, 31 Mar 2020 10:54:07 +0300 Subject: (Tags): Fixed `TimeoutError` shadowing with `asyncio.TimeoutError`. --- bot/cogs/tags.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 3729b4511..9548f2e43 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -1,7 +1,7 @@ +import asyncio import logging import re import time -from asyncio import TimeoutError from pathlib import Path from typing import Callable, Dict, Iterable, List, Optional @@ -154,7 +154,7 @@ class Tags(Cog): ) try: await self.bot.wait_for("reaction_add", timeout=60.0, check=check_trashcan) - except TimeoutError: + except asyncio.TimeoutError: await msg.remove_reaction(Emojis.trashcan, msg.author) else: await ctx.message.delete() -- cgit v1.2.3 From aa9757b30b4a9d4c65a994f90dfcc65f148ac655 Mon Sep 17 00:00:00 2001 From: ks123 Date: Tue, 31 Mar 2020 10:54:58 +0300 Subject: (Tags): Added blank line between check function and `try:` block on `send_embed_with_trashcan` function. --- bot/cogs/tags.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 9548f2e43..8115423cc 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -152,6 +152,7 @@ class Tags(Cog): and user == ctx.author and reaction.message.id == msg.id ) + try: await self.bot.wait_for("reaction_add", timeout=60.0, check=check_trashcan) except asyncio.TimeoutError: -- cgit v1.2.3 From 315ffa747c1e5b4527dce07061a3e0016eea7e5f Mon Sep 17 00:00:00 2001 From: ks123 Date: Tue, 31 Mar 2020 18:57:28 +0300 Subject: (Tags): Moved to existing `wait_for_deletion` function instead using custom/new one. --- bot/cogs/tags.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 8115423cc..3da05679e 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -13,6 +13,7 @@ from bot.bot import Bot from bot.constants import Emojis from bot.converters import TagNameConverter from bot.pagination import LinePaginator +from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) @@ -189,6 +190,7 @@ class Tags(Cog): @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.""" + def _command_on_cooldown(tag_name: str) -> bool: """ Check if the command is currently on cooldown, on a per-tag, per-channel basis. @@ -227,12 +229,22 @@ class Tags(Cog): "time": time.time(), "channel": ctx.channel.id } - await self.send_embed_with_trashcan(ctx, Embed.from_dict(tag['embed'])) + await wait_for_deletion( + await ctx.send(embed=Embed.from_dict(tag['embed'])), + [ctx.author.id], + client=self.bot + ) elif founds and len(tag_name) >= 3: - await self.send_embed_with_trashcan(ctx, Embed( - title='Did you mean ...', - description='\n'.join(tag['title'] for tag in founds[:10]) - )) + await wait_for_deletion( + await ctx.send( + embed=Embed( + title='Did you mean ...', + description='\n'.join(tag['title'] for tag in founds[:10]) + ) + ), + [ctx.author.id], + client=self.bot + ) else: tags = self._cache.values() -- cgit v1.2.3 From 44534b650d8d69e02e7fc8b0189e533cea037e25 Mon Sep 17 00:00:00 2001 From: ks123 Date: Tue, 31 Mar 2020 18:59:36 +0300 Subject: (Tags): Removed unnecessary `send_embed_with_trashcan` function due using existing function. --- bot/cogs/tags.py | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 3da05679e..a6e5952ff 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -1,16 +1,14 @@ -import asyncio import logging import re import time from pathlib import Path from typing import Callable, Dict, Iterable, List, Optional -from discord import Colour, Embed, Reaction, User +from discord import Colour, Embed from discord.ext.commands import Cog, Context, group from bot import constants from bot.bot import Bot -from bot.constants import Emojis from bot.converters import TagNameConverter from bot.pagination import LinePaginator from bot.utils.messages import wait_for_deletion @@ -142,26 +140,6 @@ class Tags(Cog): max_lines=15 ) - async def send_embed_with_trashcan(self, ctx: Context, embed: Embed) -> None: - """Send embed and handle it's and command message deletion with `trashcan` emoji.""" - msg = await ctx.send(embed=embed) - await msg.add_reaction(Emojis.trashcan) - - def check_trashcan(reaction: Reaction, user: User) -> bool: - return ( - reaction.emoji == Emojis.trashcan - and user == ctx.author - and reaction.message.id == msg.id - ) - - try: - await self.bot.wait_for("reaction_add", timeout=60.0, check=check_trashcan) - except asyncio.TimeoutError: - await msg.remove_reaction(Emojis.trashcan, msg.author) - else: - await ctx.message.delete() - await msg.delete() - @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.""" -- cgit v1.2.3 From 7954c7503f9758eb2a1051a608da386cbef70364 Mon Sep 17 00:00:00 2001 From: ks123 Date: Tue, 31 Mar 2020 19:28:02 +0300 Subject: (Infraction Edit): Don't change infraction when user try modify duration of infraction that is already expired and reason not specified. --- bot/cogs/moderation/management.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 531bb1743..6c68d852e 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -101,6 +101,11 @@ class ModManagement(commands.Cog): log_text = "" if duration is not None and not old_infraction['active']: + if reason is None: + await ctx.send( + "Expiry can't be changed (infraction already expired) and new reason not specified." + ) + return confirm_messages.append("expiry unchanged (infraction already expired)") elif isinstance(duration, str): request_data['expires_at'] = None -- cgit v1.2.3 From ad16fa81dbc3c8032e02652ba2f3e5d6704c054f Mon Sep 17 00:00:00 2001 From: Karlis S <45097959+ks129@users.noreply.github.com> Date: Tue, 31 Mar 2020 21:18:17 +0300 Subject: (Infraction Edit): Changed already expired and no reason provided sentence. Co-Authored-By: Mark --- bot/cogs/moderation/management.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 6c68d852e..250a24247 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -102,9 +102,7 @@ class ModManagement(commands.Cog): if duration is not None and not old_infraction['active']: if reason is None: - await ctx.send( - "Expiry can't be changed (infraction already expired) and new reason not specified." - ) + await ctx.send(":x: Cannot edit the expiration of an expired infraction.") return confirm_messages.append("expiry unchanged (infraction already expired)") elif isinstance(duration, str): -- cgit v1.2.3 From 45306e1fb665e683d00b1f744958404b7c7eaf9b Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 1 Apr 2020 11:29:09 +0200 Subject: Add TCD to whitelist The Coding Den is a language agnostic community that's been around for years with over 12000 members. I think we can allow that invite in our community. --- config-default.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config-default.yml b/config-default.yml index ef0ed970f..a9578d9bb 100644 --- a/config-default.yml +++ b/config-default.yml @@ -279,6 +279,7 @@ filter: - 524691714909274162 # Panda3D - 336642139381301249 # discord.py - 405403391410438165 # Sentdex + - 172018499005317120 # The Coding Den domain_blacklist: - pornhub.com -- cgit v1.2.3 From 10400899806408e1d9966fbe1a1c7c0e9ccaa087 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Thu, 2 Apr 2020 09:19:12 -0400 Subject: Fixed missed rename for token removal method name change --- bot/cogs/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index e897b30ff..7b66b48c2 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -238,7 +238,7 @@ class BotCog(Cog, name="Bot"): ) and not msg.author.bot and len(msg.content.splitlines()) > 3 - and not TokenRemover.is_token_in_message(msg) + and not TokenRemover.find_token_in_message(msg) ) if parse_codeblock: # no token in the msg -- cgit v1.2.3 From 648bd4c760e73be9dc2fbac733fb0547bbb4a477 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Sun, 5 Apr 2020 13:57:21 +0100 Subject: Reduce span of hyperlink in AVAILABLE_MSG and DORMANT_MSG --- bot/cogs/help_channels.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index ff8d31ded..2b0b463c4 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -47,7 +47,7 @@ currently cannot send a message in this channel, it means you are on cooldown an Try to write the best question you can by providing a detailed description and telling us what \ you've tried already. For more information on asking a good question, \ -[check out our guide on asking good questions]({ASKING_GUIDE_URL}). +check out our guide on [asking good questions]({ASKING_GUIDE_URL}). """ DORMANT_MSG = f""" @@ -58,7 +58,7 @@ channel until it becomes available again. If your question wasn't answered yet, you can claim a new help channel from the \ **Help: Available** category by simply asking your question again. Consider rephrasing the \ question to maximize your chance of getting a good answer. If you're not sure how, have a look \ -through [our guide for asking a good question]({ASKING_GUIDE_URL}). +through our guide for [asking a good question]({ASKING_GUIDE_URL}). """ -- cgit v1.2.3 From 0da93b1b64368cbcf3fe14dd1fe923a3fd0af427 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Sun, 5 Apr 2020 14:00:31 +0100 Subject: Add close alias for dormant command --- 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 2b0b463c4..b820c7ad3 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -184,7 +184,7 @@ class HelpChannels(Scheduler, commands.Cog): log.trace("Populating the name queue with names.") return deque(available_names) - @commands.command(name="dormant", enabled=False) + @commands.command(name="dormant", aliases=["close"], enabled=False) @with_role(*constants.HelpChannels.cmd_whitelist) async def dormant_command(self, ctx: commands.Context) -> None: """Make the current in-use help channel dormant.""" -- cgit v1.2.3 From 7571cabe65e39d231523e713923cd23b927225bc Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Sun, 5 Apr 2020 17:09:35 +0200 Subject: Change help channel sorting to bottom position The current sorting algorithm we used created unpredictable channel order (for our human end-users) and induced a flickering channel light-show in Discord clients. To combat these undesirable side-effects, I've changed the ordering to always order channels at the bottom of a category. This also means that channels looking for answers the longest will naturally float up. --- bot/cogs/help_channels.py | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index b820c7ad3..68dbdf9ed 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -1,5 +1,4 @@ import asyncio -import bisect import inspect import json import logging @@ -219,25 +218,15 @@ class HelpChannels(Scheduler, commands.Cog): @staticmethod def get_position(channel: discord.TextChannel, destination: discord.CategoryChannel) -> int: - """Return alphabetical position for `channel` if moved to `destination`.""" - log.trace(f"Getting alphabetical position for #{channel} ({channel.id}).") + """Return the position to sort the `channel` at the bottom if moved to `destination`.""" + log.trace(f"Getting bottom position for #{channel} ({channel.id}).") - # If the destination category is empty, use the first position if not destination.channels: + # If the destination category is empty, use the first position position = 1 else: - # Make a sorted list of channel names for bisect. - channel_names = [c.name for c in destination.channels] - - # Get location which would maintain sorted order if channel was inserted into the list. - rank = bisect.bisect(channel_names, channel.name) - - if rank == len(destination.channels): - # Channel should be moved to the end of the category. - position = destination.channels[-1].position + 1 - else: - # Channel should be moved to the position of its alphabetical successor. - position = destination.channels[rank].position + # Else use the maximum position int + 1 + position = max(c.position for c in destination.channels) + 1 log.trace( f"Position of #{channel} ({channel.id}) in {destination.name} will be {position} " @@ -464,7 +453,7 @@ class HelpChannels(Scheduler, commands.Cog): category=self.in_use_category, sync_permissions=True, topic=IN_USE_TOPIC, - position=0, + position=self.get_position(channel, self.in_use_category), ) timeout = constants.HelpChannels.idle_minutes * 60 -- cgit v1.2.3 From 4cb5030fd524c4dd8adce9c9e1fe9cc26228ad9b Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 6 Apr 2020 17:25:44 +0200 Subject: Add channel status emoji to help channels I've added channel status emojis as a prefix to our help channels to make it more obvious to the end user what the current status of a channel is. All channels in the Available category will be marked with a green checkmark emoji, while all channels in the In Use category will be marked with an hourglass. Channels in the Dormant category stay unadorned. Channels will be stripped of their previous prefix when moved to another category. This relies on the `help-` naming convention, as that is the most reliable way to do it that does not break if we ever opt for another emoji. --- bot/cogs/help_channels.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 68dbdf9ed..4fddba627 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -60,6 +60,10 @@ question to maximize your chance of getting a good answer. If you're not sure ho through our guide for [asking a good question]({ASKING_GUIDE_URL}). """ +AVAILABLE_EMOJI = "✅" +IN_USE_EMOJI = "⌛" +NAME_SEPARATOR = "|" + class TaskData(t.NamedTuple): """Data for a scheduled task.""" @@ -235,6 +239,20 @@ class HelpChannels(Scheduler, commands.Cog): return position + @staticmethod + def get_clean_channel_name(channel: discord.TextChannel) -> str: + """Return a clean channel name without status emojis prefix.""" + try: + # Try to remove the status prefix using the index of "help-" + name = channel.name[channel.name.index("help-"):] + log.trace(f"The clean name for `{channel}` is `{name}`") + except ValueError: + # If, for some reason, the channel name does not contain "help-" fall back gracefully + log.info(f"Can't get clean name as `{channel}` does not follow the `help-` naming convention.") + name = channel.name + + return name + @staticmethod def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: """Yield the text channels of the `category` in an unsorted manner.""" @@ -419,7 +437,9 @@ class HelpChannels(Scheduler, commands.Cog): await self.send_available_message(channel) log.trace(f"Moving #{channel} ({channel.id}) to the Available category.") + await channel.edit( + name=f"{AVAILABLE_EMOJI}{NAME_SEPARATOR}{self.get_clean_channel_name(channel)}", category=self.available_category, sync_permissions=True, topic=AVAILABLE_TOPIC, @@ -430,6 +450,7 @@ class HelpChannels(Scheduler, commands.Cog): log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") await channel.edit( + name=self.get_clean_channel_name(channel), category=self.dormant_category, sync_permissions=True, topic=DORMANT_TOPIC, @@ -450,6 +471,7 @@ class HelpChannels(Scheduler, commands.Cog): log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") await channel.edit( + name=f"{IN_USE_EMOJI}{NAME_SEPARATOR}{self.get_clean_channel_name(channel)}", category=self.in_use_category, sync_permissions=True, topic=IN_USE_TOPIC, -- cgit v1.2.3 From ae49def47a2f956b93c814993b8f380b5182c6b7 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 6 Apr 2020 17:32:57 +0200 Subject: Change bottom sorting strategy to using a large int The current approach of trying to find the maximum channel position, adding one, and using that as the position integer for channels does not seem to work reliably. An approach that seems to work in the testing environment is using a very large integer for the position attribute of the channel: It wil be sorted at the bottom and Discord will automatically scale the integer down to `max + 1`. This also means the `get_position` utility function is no longer needed; it has been removed. --- bot/cogs/help_channels.py | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 4fddba627..60580695c 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -220,25 +220,6 @@ class HelpChannels(Scheduler, commands.Cog): return channel - @staticmethod - def get_position(channel: discord.TextChannel, destination: discord.CategoryChannel) -> int: - """Return the position to sort the `channel` at the bottom if moved to `destination`.""" - log.trace(f"Getting bottom position for #{channel} ({channel.id}).") - - if not destination.channels: - # If the destination category is empty, use the first position - position = 1 - else: - # Else use the maximum position int + 1 - position = max(c.position for c in destination.channels) + 1 - - log.trace( - f"Position of #{channel} ({channel.id}) in {destination.name} will be {position} " - f"(was {channel.position})." - ) - - return position - @staticmethod def get_clean_channel_name(channel: discord.TextChannel) -> str: """Return a clean channel name without status emojis prefix.""" @@ -454,7 +435,7 @@ class HelpChannels(Scheduler, commands.Cog): category=self.dormant_category, sync_permissions=True, topic=DORMANT_TOPIC, - position=self.get_position(channel, self.dormant_category), + position=10000, ) log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.") @@ -475,7 +456,7 @@ class HelpChannels(Scheduler, commands.Cog): category=self.in_use_category, sync_permissions=True, topic=IN_USE_TOPIC, - position=self.get_position(channel, self.in_use_category), + position=10000, ) timeout = constants.HelpChannels.idle_minutes * 60 -- cgit v1.2.3 From dfbecd2077c72d7475aaece4a3f92a12b56a207c Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 6 Apr 2020 18:06:39 +0200 Subject: Use configurable prefix to clean help channel names The help channel prefix is configurable as a constant, but I accidentally used a static prefix in the utility function that cleaned the channel names. This commit makes sure the utility method uses the prefix defined in the constants. --- bot/cogs/help_channels.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 60580695c..2e203df46 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -223,13 +223,14 @@ class HelpChannels(Scheduler, commands.Cog): @staticmethod def get_clean_channel_name(channel: discord.TextChannel) -> str: """Return a clean channel name without status emojis prefix.""" + prefix = constants.HelpChannels.name_prefix try: - # Try to remove the status prefix using the index of "help-" - name = channel.name[channel.name.index("help-"):] + # Try to remove the status prefix using the index of the channel prefix + name = channel.name[channel.name.index(prefix):] log.trace(f"The clean name for `{channel}` is `{name}`") except ValueError: # If, for some reason, the channel name does not contain "help-" fall back gracefully - log.info(f"Can't get clean name as `{channel}` does not follow the `help-` naming convention.") + log.info(f"Can't get clean name as `{channel}` does not follow the `{prefix}` naming convention.") name = channel.name return name -- cgit v1.2.3 From e3d7afa44346ee7d2e123668e55b623dc901515d Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 6 Apr 2020 21:18:09 +0200 Subject: Use clean help channel name for used name set The set that keeps track of the used channel names should discard emojis. To do that, I'm cleaning the names before they're added to the set of channel names. --- 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 2e203df46..1e062ca46 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -273,7 +273,7 @@ class HelpChannels(Scheduler, commands.Cog): names = set() for cat in (self.available_category, self.in_use_category, self.dormant_category): for channel in self.get_category_channels(cat): - names.add(channel.name) + names.add(self.get_clean_channel_name(channel)) if len(names) > MAX_CHANNELS_PER_CATEGORY: log.warning( -- cgit v1.2.3 From c4015d8137e7541f1e76666e28b3e707524e7d06 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Tue, 7 Apr 2020 09:30:37 +0200 Subject: Set the ID of the new Help: In Use category As Discord is having a rather persistent issue with one of the channels in the current `Help: In Use` category, we're going to start using a new category that excludes the old channel. The old channel, help-argon, appears to be completely broken on Discord's end, resulting in "Not found" errors for any kind of interaction, including channel move and/or channel delete admin actions. As it's still visible, it's currently triggering a lot questions from our members. We hope that using a new category will fix that. --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 70c31ebb5..896003973 100644 --- a/config-default.yml +++ b/config-default.yml @@ -112,7 +112,7 @@ guild: categories: help_available: 691405807388196926 - help_in_use: 356013061213126657 + help_in_use: 696958401460043776 help_dormant: 691405908919451718 channels: -- cgit v1.2.3 From 0460dc29a5be15d9e11fd70d37041affe1cd4ba2 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Tue, 7 Apr 2020 20:04:24 +0200 Subject: Change help available embed to use occupied term The embed displayed in available help channels still used the term "in use" instead of "occupied". I've updated the embed to reflect the new name of the "occupied" category. --- bot/cogs/help_channels.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 1e062ca46..915961b34 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -37,9 +37,9 @@ channels in the Help: Available category. AVAILABLE_MSG = f""" This help channel is now **available**, which means that you can claim it by simply typing your \ -question into it. Once claimed, the channel will move into the **Help: In Use** category, and will \ -be yours until it has been inactive for {constants.HelpChannels.idle_minutes} minutes. When that \ -happens, it will be set to **dormant** and moved into the **Help: Dormant** category. +question into it. Once claimed, the channel will move into the **Python Help: Occupied** category, \ +and will be yours until it has been inactive for {constants.HelpChannels.idle_minutes} minutes. When \ +that happens, it will be set to **dormant** and moved into the **Help: Dormant** category. You may claim a new channel once every {constants.HelpChannels.claim_minutes} minutes. If you \ currently cannot send a message in this channel, it means you are on cooldown and need to wait. -- cgit v1.2.3 From 8ca0b7f5161f0a71df39a36758b3d8041c895fe8 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Tue, 7 Apr 2020 20:06:00 +0200 Subject: Ensure available help channels sync their permissions The help channels in the `Help: Available` category should automatically synchronize their permissions with the category permissions to ensure that the overwrites we use to prevent people from claiming multiple help channels are properly enforced. Unfortunately, for unknown reasons, they sometimes get in an "out of sync" state that requires intervention to get them back in sync. This PR mitigates that issue by checking the available channel for their synchronisation status during certain critical times in our help channel system: 1. Whenever the overwrites for the category change 2. Whenever a channel is moved into the new category 3. After the categories have been reset during the initialization process The check is straightforward: The `ensure_permissions_synchronization` method iterates over all the channels in the category and checks if the channels are currently synchronizing their permissions. If not, we remedy that by making a channel edit request to the Discord API. If all channels were already "in sync", no API calls are made. The latter should make this an inexpensive mitigation procedure: As we typically have very few channels in the available category and channels mostly stay in sync, we typically do very little. To make this process a bit easier, I've factored out `set_permissions` calls to a helper function that also calls the `ensure_permissions_synchronization` method. The only exception is during the reset process: As we may edit multiple permissions in this loop, it's better to only ensure the synchronization after we're done with all permission changes. --- bot/cogs/help_channels.py | 51 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 915961b34..697a4d3b7 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -427,6 +427,12 @@ class HelpChannels(Scheduler, commands.Cog): topic=AVAILABLE_TOPIC, ) + log.trace( + f"Ensuring that all channels in `{self.available_category}` have " + f"synchronized permissions after moving `{channel}` into it." + ) + await self.ensure_permissions_synchronization(self.available_category) + async def move_to_dormant(self, channel: discord.TextChannel) -> None: """Make the `channel` dormant.""" log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") @@ -544,6 +550,39 @@ class HelpChannels(Scheduler, commands.Cog): # be put in the queue. await self.move_to_available() + @staticmethod + async def ensure_permissions_synchronization(category: discord.CategoryChannel) -> None: + """ + Ensure that all channels in the `category` have their permissions synchronized. + + This method mitigates an issue we have yet to find the cause for: Every so often, a channel in the + `Help: Available` category gets in a state in which it will no longer synchronizes its permissions + with the category. To prevent that, we iterate over the channels in the category and edit the channels + that are observed to be in such a state. If no "out of sync" channels are observed, this method will + not make API calls and should be fairly inexpensive to run. + """ + for channel in category.channels: + if not channel.permissions_synced: + log.info(f"The permissions of channel `{channel}` were out of sync with category `{category}`.") + await channel.edit(sync_permissions=True) + + async def update_category_permissions( + self, category: discord.CategoryChannel, member: discord.Member, **permissions + ) -> None: + """ + Update the permissions of the given `member` for the given `category` with `permissions` passed. + + After updating the permissions for the member in the category, this helper function will call the + `ensure_permissions_synchronization` method to ensure that all channels are still synchronizing their + permissions with the category. It's currently unknown why some channels get "out of sync", but this + hopefully mitigates the issue. + """ + log.trace(f"Updating permissions for `{member}` in `{category}` with {permissions}.") + await category.set_permissions(member, **permissions) + + log.trace(f"Ensuring that all channels in `{category}` are synchronized after permissions update.") + await self.ensure_permissions_synchronization(category) + async def reset_send_permissions(self) -> None: """Reset send permissions for members with it set to False in the Available category.""" log.trace("Resetting send permissions in the Available category.") @@ -551,7 +590,13 @@ class HelpChannels(Scheduler, commands.Cog): for member, overwrite in self.available_category.overwrites.items(): if isinstance(member, discord.Member) and overwrite.send_messages is False: log.trace(f"Resetting send permissions for {member} ({member.id}).") - await self.available_category.set_permissions(member, send_messages=None) + + # We don't use the permissions helper function here as we may have to reset multiple overwrites + # and we don't want to enforce the permissions synchronization in each iteration. + await self.available_category.set_permissions(member, overwrite=None) + + log.trace(f"Ensuring channels in `Help: Available` are synchronized after permissions reset.") + await self.ensure_permissions_synchronization(self.available_category) async def revoke_send_permissions(self, member: discord.Member) -> None: """ @@ -564,14 +609,14 @@ class HelpChannels(Scheduler, commands.Cog): f"Revoking {member}'s ({member.id}) send message permissions in the Available category." ) - await self.available_category.set_permissions(member, send_messages=False) + await self.update_category_permissions(self.available_category, member, send_messages=False) # Cancel the existing task, if any. # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner). self.cancel_task(member.id, ignore_missing=True) timeout = constants.HelpChannels.claim_minutes * 60 - callback = self.available_category.set_permissions(member, overwrite=None) + callback = self.update_category_permissions(self.available_category, member, overwrite=None) log.trace(f"Scheduling {member}'s ({member.id}) send message permissions to be reinstated.") self.schedule_task(member.id, TaskData(timeout, callback)) -- cgit v1.2.3 From 12c80fb664e612a2319dfde7d341737159934e9c Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Wed, 8 Apr 2020 19:08:50 +0200 Subject: Stop setting positions when moving help channels Unfortunately, trying to set positions for the help channels during their move from one category to another does not work to well. It introduces a number of glitches and we haven't been able to reliably get a channel to go to a specific position either. This commit simply removes any attempt to set a position and lets Discord handle it. --- bot/cogs/help_channels.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 697a4d3b7..797019f69 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -442,7 +442,6 @@ class HelpChannels(Scheduler, commands.Cog): category=self.dormant_category, sync_permissions=True, topic=DORMANT_TOPIC, - position=10000, ) log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.") @@ -463,7 +462,6 @@ class HelpChannels(Scheduler, commands.Cog): category=self.in_use_category, sync_permissions=True, topic=IN_USE_TOPIC, - position=10000, ) timeout = constants.HelpChannels.idle_minutes * 60 -- cgit v1.2.3 From 6f4213fff0ed80d0376159dd84a27e276fbb303b Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 9 Apr 2020 14:38:06 +0300 Subject: (Syncers): Fixed wrong except statement Replaced `TimeoutError` with `asyncio.TimeoutError`. --- bot/cogs/sync/syncers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 003bf3727..e55bf27fd 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -1,4 +1,5 @@ import abc +import asyncio import logging import typing as t from collections import namedtuple @@ -122,7 +123,7 @@ class Syncer(abc.ABC): check=partial(self._reaction_check, author, message), timeout=constants.Sync.confirm_timeout ) - except TimeoutError: + except asyncio.TimeoutError: # reaction will remain none thus sync will be aborted in the finally block below. log.debug(f"The {self.name} syncer confirmation prompt timed out.") -- cgit v1.2.3 From 7434ed3152e6d3f89babe2fef332983925d04434 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 9 Apr 2020 14:45:32 +0300 Subject: (Syncer Tests): Replaced wrong side effect Replaced `TimeoutError` with `asyncio.TimeoutError`. --- tests/bot/cogs/sync/test_base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 6ee9dfda6..70aea2bab 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -1,3 +1,4 @@ +import asyncio import unittest from unittest import mock @@ -211,7 +212,7 @@ class SyncerConfirmationTests(unittest.IsolatedAsyncioTestCase): subtests = ( (constants.Emojis.check_mark, True, None), ("InVaLiD", False, None), - (None, False, TimeoutError), + (None, False, asyncio.TimeoutError), ) for emoji, ret_val, side_effect in subtests: -- cgit v1.2.3 From c608963a0672b8396190f521366799d649a8cb90 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 6 Apr 2020 00:15:07 +0200 Subject: Remove dormant invokation message after move. --- bot/cogs/help_channels.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 697a4d3b7..75b61f6cb 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -196,6 +196,7 @@ class HelpChannels(Scheduler, commands.Cog): if ctx.channel.category == self.in_use_category: self.cancel_task(ctx.channel.id) await self.move_to_dormant(ctx.channel) + await ctx.message.delete() else: log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") -- cgit v1.2.3 From 1a58b34565e612a48d8dc59cb3f4ed75ee593744 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 6 Apr 2020 01:46:57 +0200 Subject: Allow help session starters to invoke dormant. Removing the `with_role` check from the command and replcaing it with a new `dormant_check` that's used in the body, which also checks against a cache of users that started the sessions, allows them to close their own channels along with the role check. --- bot/cogs/help_channels.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 75b61f6cb..cdfe4e72c 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -13,7 +13,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot -from bot.decorators import with_role +from bot.utils.checks import with_role_check from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) @@ -108,6 +108,7 @@ class HelpChannels(Scheduler, commands.Cog): super().__init__() self.bot = bot + self.help_channel_users: t.Dict[discord.User, discord.TextChannel] = {} # Categories self.available_category: discord.CategoryChannel = None @@ -187,16 +188,24 @@ class HelpChannels(Scheduler, commands.Cog): log.trace("Populating the name queue with names.") return deque(available_names) + async def dormant_check(self, ctx: commands.Context) -> bool: + """Return True if the user started the help channel session or passes the role check.""" + if self.help_channel_users.get(ctx.author) == ctx.channel: + log.trace(f"{ctx.author} started the help session, passing the check for dormant.") + return True + + log.trace(f"{ctx.author} did not start the help session, checking roles.") + return with_role_check(ctx, *constants.HelpChannels.cmd_whitelist) + @commands.command(name="dormant", aliases=["close"], enabled=False) - @with_role(*constants.HelpChannels.cmd_whitelist) async def dormant_command(self, ctx: commands.Context) -> None: """Make the current in-use help channel dormant.""" log.trace("dormant command invoked; checking if the channel is in-use.") - if ctx.channel.category == self.in_use_category: - self.cancel_task(ctx.channel.id) - await self.move_to_dormant(ctx.channel) - await ctx.message.delete() + if await self.dormant_check(ctx): + self.cancel_task(ctx.channel.id) + await self.move_to_dormant(ctx.channel) + await ctx.message.delete() else: log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") @@ -543,6 +552,8 @@ class HelpChannels(Scheduler, commands.Cog): await self.move_to_in_use(channel) await self.revoke_send_permissions(message.author) + # Add user with channel for dormant check. + self.help_channel_users[message.author] = channel log.trace(f"Releasing on_message lock for {message.id}.") -- cgit v1.2.3 From 7ccfa21f38b0df50f582ceb6e64d1ce1fe9c6617 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 6 Apr 2020 02:02:41 +0200 Subject: Reset cooldown after channel is made dormant. --- bot/cogs/help_channels.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index cdfe4e72c..7bfb33875 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -206,6 +206,7 @@ class HelpChannels(Scheduler, commands.Cog): self.cancel_task(ctx.channel.id) await self.move_to_dormant(ctx.channel) await ctx.message.delete() + await self.reset_send_permissions_for_help_user(ctx.channel) else: log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") @@ -610,6 +611,19 @@ class HelpChannels(Scheduler, commands.Cog): log.trace(f"Ensuring channels in `Help: Available` are synchronized after permissions reset.") await self.ensure_permissions_synchronization(self.available_category) + async def reset_send_permissions_for_help_user(self, channel: discord.TextChannel) -> None: + """Reset send permissions in the Available category for member that started the help session in `channel`.""" + # Get mapping of channels to users that started the help session in them. + channels_to_users = {value: key for key, value in self.help_channel_users.items()} + log.trace(f"Attempting to find user for help session in #{channel.name} ({channel.id}).") + try: + member: discord.Member = channels_to_users[channel] + except KeyError: + log.trace(f"Channel #{channel.name} ({channel.id}) not in help session cache, permissions unchanged.") + return + log.trace(f"Resetting send permissions for {member} ({member.id}).") + await self.available_category.set_permissions(member, send_messages=None) + async def revoke_send_permissions(self, member: discord.Member) -> None: """ Disallow `member` to send messages in the Available category for a certain time. -- cgit v1.2.3 From c6355f67192e247f6b207cb0e18b2ff80d5b710f Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 6 Apr 2020 02:03:13 +0200 Subject: Extend docstrings to include new behaviour. --- bot/cogs/help_channels.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 7bfb33875..912ce4f44 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -199,7 +199,13 @@ class HelpChannels(Scheduler, commands.Cog): @commands.command(name="dormant", aliases=["close"], enabled=False) async def dormant_command(self, ctx: commands.Context) -> None: - """Make the current in-use help channel dormant.""" + """ + Make the current in-use help channel dormant. + + Make the channel dormant if the user passes the `dormant_check`, + delete the message that invoked this, + and reset the send permissions cooldown for the user who started the session. + """ log.trace("dormant command invoked; checking if the channel is in-use.") if ctx.channel.category == self.in_use_category: if await self.dormant_check(ctx): -- cgit v1.2.3 From acee84d044c65b9a8d6ab4a164d189fa0eaa174a Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 6 Apr 2020 02:06:07 +0200 Subject: Handle dormant invokation not being found. --- bot/cogs/help_channels.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 912ce4f44..bc973cd4d 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -5,6 +5,7 @@ import logging import random import typing as t from collections import deque +from contextlib import suppress from datetime import datetime from pathlib import Path @@ -211,7 +212,9 @@ class HelpChannels(Scheduler, commands.Cog): if await self.dormant_check(ctx): self.cancel_task(ctx.channel.id) await self.move_to_dormant(ctx.channel) - await ctx.message.delete() + with suppress(discord.errors.NotFound): + await ctx.message.delete() + log.trace("Deleting dormant invokation message.") await self.reset_send_permissions_for_help_user(ctx.channel) else: log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") -- cgit v1.2.3 From c2e9ec459cd28832a1e797d7c755b5aab57d69dd Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 6 Apr 2020 02:13:36 +0200 Subject: Cancel permission restoration task. After the dormant command is used and the permissions are restored for the user that started the session, the task for restoring them after the claim time has passed is no longer necessary. --- bot/cogs/help_channels.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index bc973cd4d..03bac27a4 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -632,6 +632,8 @@ class HelpChannels(Scheduler, commands.Cog): return log.trace(f"Resetting send permissions for {member} ({member.id}).") await self.available_category.set_permissions(member, send_messages=None) + # Cancel task, ignore no task existing when the claim time passed but idle time has not. + self.cancel_task(member.id, ignore_missing=True) async def revoke_send_permissions(self, member: discord.Member) -> None: """ -- cgit v1.2.3 From 7f1be2882dde670d12000fe661424d3b6f406f89 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 6 Apr 2020 22:34:17 +0200 Subject: Reverse help_channel_user pairs. Pairing users to channels was a design flaw, because the keys didn't get overwritten. This allowed multiple users to access the dormant command for the running session of the bot. Replacing this with a reversed paring fixes both issues because the cache is overwritten on channel activation. --- bot/cogs/help_channels.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 03bac27a4..dbe7cedc1 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -109,7 +109,7 @@ class HelpChannels(Scheduler, commands.Cog): super().__init__() self.bot = bot - self.help_channel_users: t.Dict[discord.User, discord.TextChannel] = {} + self.help_channel_users: t.Dict[discord.TextChannel, discord.User] = {} # Categories self.available_category: discord.CategoryChannel = None @@ -191,7 +191,7 @@ class HelpChannels(Scheduler, commands.Cog): async def dormant_check(self, ctx: commands.Context) -> bool: """Return True if the user started the help channel session or passes the role check.""" - if self.help_channel_users.get(ctx.author) == ctx.channel: + if self.help_channel_users.get(ctx.channel) == ctx.author: log.trace(f"{ctx.author} started the help session, passing the check for dormant.") return True @@ -563,7 +563,7 @@ class HelpChannels(Scheduler, commands.Cog): await self.move_to_in_use(channel) await self.revoke_send_permissions(message.author) # Add user with channel for dormant check. - self.help_channel_users[message.author] = channel + self.help_channel_users[channel] = message.author log.trace(f"Releasing on_message lock for {message.id}.") @@ -622,11 +622,9 @@ class HelpChannels(Scheduler, commands.Cog): async def reset_send_permissions_for_help_user(self, channel: discord.TextChannel) -> None: """Reset send permissions in the Available category for member that started the help session in `channel`.""" - # Get mapping of channels to users that started the help session in them. - channels_to_users = {value: key for key, value in self.help_channel_users.items()} log.trace(f"Attempting to find user for help session in #{channel.name} ({channel.id}).") try: - member: discord.Member = channels_to_users[channel] + member = self.help_channel_users[channel] except KeyError: log.trace(f"Channel #{channel.name} ({channel.id}) not in help session cache, permissions unchanged.") return -- cgit v1.2.3 From 25c15171020613f0123ed83654adad5c7c584d84 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 7 Apr 2020 01:11:37 +0200 Subject: Delete overwrite instead of send_messages permission. Only resetting the permission caused the overwrites for the users to remain on the category, potentially piling up and causing further issues. --- 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 dbe7cedc1..f49149d8a 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -629,7 +629,7 @@ class HelpChannels(Scheduler, commands.Cog): log.trace(f"Channel #{channel.name} ({channel.id}) not in help session cache, permissions unchanged.") return log.trace(f"Resetting send permissions for {member} ({member.id}).") - await self.available_category.set_permissions(member, send_messages=None) + await self.available_category.set_permissions(member, overwrite=None) # Cancel task, ignore no task existing when the claim time passed but idle time has not. self.cancel_task(member.id, ignore_missing=True) -- cgit v1.2.3 From 4d6c342d2a35a54f65f25a973994c7dc55ca4be8 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 7 Apr 2020 01:12:57 +0200 Subject: Change names to more descriptive ones. --- bot/cogs/help_channels.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index f49149d8a..73c7adb15 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -109,7 +109,7 @@ class HelpChannels(Scheduler, commands.Cog): super().__init__() self.bot = bot - self.help_channel_users: t.Dict[discord.TextChannel, discord.User] = {} + self.help_channel_claimants: t.Dict[discord.TextChannel, discord.User] = {} # Categories self.available_category: discord.CategoryChannel = None @@ -191,7 +191,7 @@ class HelpChannels(Scheduler, commands.Cog): async def dormant_check(self, ctx: commands.Context) -> bool: """Return True if the user started the help channel session or passes the role check.""" - if self.help_channel_users.get(ctx.channel) == ctx.author: + if self.help_channel_claimants.get(ctx.channel) == ctx.author: log.trace(f"{ctx.author} started the help session, passing the check for dormant.") return True @@ -215,7 +215,7 @@ class HelpChannels(Scheduler, commands.Cog): with suppress(discord.errors.NotFound): await ctx.message.delete() log.trace("Deleting dormant invokation message.") - await self.reset_send_permissions_for_help_user(ctx.channel) + await self.reset_claimant_send_permission(ctx.channel) else: log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") @@ -563,7 +563,7 @@ class HelpChannels(Scheduler, commands.Cog): await self.move_to_in_use(channel) await self.revoke_send_permissions(message.author) # Add user with channel for dormant check. - self.help_channel_users[channel] = message.author + self.help_channel_claimants[channel] = message.author log.trace(f"Releasing on_message lock for {message.id}.") @@ -620,11 +620,11 @@ class HelpChannels(Scheduler, commands.Cog): log.trace(f"Ensuring channels in `Help: Available` are synchronized after permissions reset.") await self.ensure_permissions_synchronization(self.available_category) - async def reset_send_permissions_for_help_user(self, channel: discord.TextChannel) -> None: + async def reset_claimant_send_permission(self, channel: discord.TextChannel) -> None: """Reset send permissions in the Available category for member that started the help session in `channel`.""" log.trace(f"Attempting to find user for help session in #{channel.name} ({channel.id}).") try: - member = self.help_channel_users[channel] + member = self.help_channel_claimants[channel] except KeyError: log.trace(f"Channel #{channel.name} ({channel.id}) not in help session cache, permissions unchanged.") return -- cgit v1.2.3 From 52f19185752358cff21aadc7902493f78030a94f Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 7 Apr 2020 01:14:21 +0200 Subject: Reword strings to reflect name changes. --- bot/cogs/help_channels.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 73c7adb15..3418ad210 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -190,12 +190,12 @@ class HelpChannels(Scheduler, commands.Cog): return deque(available_names) async def dormant_check(self, ctx: commands.Context) -> bool: - """Return True if the user started the help channel session or passes the role check.""" + """Return True if the user is the help channel claimant or passes the role check.""" if self.help_channel_claimants.get(ctx.channel) == ctx.author: - log.trace(f"{ctx.author} started the help session, passing the check for dormant.") + log.trace(f"{ctx.author} is the help channel claimant, passing the check for dormant.") return True - log.trace(f"{ctx.author} did not start the help session, checking roles.") + log.trace(f"{ctx.author} is not the help channel claimant, checking roles.") return with_role_check(ctx, *constants.HelpChannels.cmd_whitelist) @commands.command(name="dormant", aliases=["close"], enabled=False) @@ -621,12 +621,12 @@ class HelpChannels(Scheduler, commands.Cog): await self.ensure_permissions_synchronization(self.available_category) async def reset_claimant_send_permission(self, channel: discord.TextChannel) -> None: - """Reset send permissions in the Available category for member that started the help session in `channel`.""" - log.trace(f"Attempting to find user for help session in #{channel.name} ({channel.id}).") + """Reset send permissions in the Available category for the help `channel` claimant.""" + log.trace(f"Attempting to find claimant for #{channel.name} ({channel.id}).") try: member = self.help_channel_claimants[channel] except KeyError: - log.trace(f"Channel #{channel.name} ({channel.id}) not in help session cache, permissions unchanged.") + log.trace(f"Channel #{channel.name} ({channel.id}) not in claimant cache, permissions unchanged.") return log.trace(f"Resetting send permissions for {member} ({member.id}).") await self.available_category.set_permissions(member, overwrite=None) -- cgit v1.2.3 From a0daa7e77c4920fc51229fb751a48d08280ed42d Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 7 Apr 2020 01:20:03 +0200 Subject: Add spacing. Co-authored-by: MarkKoz --- bot/cogs/help_channels.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 3418ad210..421d41baf 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -628,6 +628,7 @@ class HelpChannels(Scheduler, commands.Cog): except KeyError: log.trace(f"Channel #{channel.name} ({channel.id}) not in claimant cache, permissions unchanged.") return + log.trace(f"Resetting send permissions for {member} ({member.id}).") await self.available_category.set_permissions(member, overwrite=None) # Cancel task, ignore no task existing when the claim time passed but idle time has not. -- cgit v1.2.3 From 3532d06db7083c8a7dc6a4b87a28d11423d2e605 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 7 Apr 2020 01:20:48 +0200 Subject: Reword comment. Co-authored-by: MarkKoz --- 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 421d41baf..52af03d27 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -631,7 +631,7 @@ class HelpChannels(Scheduler, commands.Cog): log.trace(f"Resetting send permissions for {member} ({member.id}).") await self.available_category.set_permissions(member, overwrite=None) - # Cancel task, ignore no task existing when the claim time passed but idle time has not. + # Ignore missing task when claim cooldown has passed but the channel still isn't dormant. self.cancel_task(member.id, ignore_missing=True) async def revoke_send_permissions(self, member: discord.Member) -> None: -- cgit v1.2.3 From 44333ae53b6692ab34b4fe967ecbf4f215e7fb0e Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 9 Apr 2020 15:20:02 +0200 Subject: Move message deletion up. --- bot/cogs/help_channels.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 52af03d27..8dca2ede0 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -210,11 +210,12 @@ class HelpChannels(Scheduler, commands.Cog): log.trace("dormant command invoked; checking if the channel is in-use.") if ctx.channel.category == self.in_use_category: if await self.dormant_check(ctx): - self.cancel_task(ctx.channel.id) - await self.move_to_dormant(ctx.channel) with suppress(discord.errors.NotFound): - await ctx.message.delete() log.trace("Deleting dormant invokation message.") + await ctx.message.delete() + + self.cancel_task(ctx.channel.id) + await self.move_to_dormant(ctx.channel) await self.reset_claimant_send_permission(ctx.channel) else: log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") -- cgit v1.2.3 From 93a511da2be49305bc27fcce18f10f9758418dc5 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 9 Apr 2020 15:22:55 +0200 Subject: Move permissions reset up. --- bot/cogs/help_channels.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 8dca2ede0..f53f7a7ba 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -214,9 +214,10 @@ class HelpChannels(Scheduler, commands.Cog): log.trace("Deleting dormant invokation message.") await ctx.message.delete() + await self.reset_claimant_send_permission(ctx.channel) + self.cancel_task(ctx.channel.id) await self.move_to_dormant(ctx.channel) - await self.reset_claimant_send_permission(ctx.channel) else: log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") -- cgit v1.2.3 From 221426d91fa1db5f334562ac0af52d93bbe7ab10 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 9 Apr 2020 15:23:26 +0200 Subject: Suppress errors when resetting permissions. --- bot/cogs/help_channels.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index f53f7a7ba..a6fa05d90 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -214,7 +214,8 @@ class HelpChannels(Scheduler, commands.Cog): log.trace("Deleting dormant invokation message.") await ctx.message.delete() - await self.reset_claimant_send_permission(ctx.channel) + with suppress(discord.errors.HTTPException, discord.errors.NotFound): + await self.reset_claimant_send_permission(ctx.channel) self.cancel_task(ctx.channel.id) await self.move_to_dormant(ctx.channel) -- cgit v1.2.3 From 9d6425474b8efa2dd4aba0086c1e8aaeb5eafeed Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 9 Apr 2020 08:00:39 -0700 Subject: HelpChannels: check author of dormant message In a testing environment, the bot may try to edit the message of a different bot. Therefore, the author of the message should be checked to ensure the current bot sent it. --- bot/cogs/help_channels.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index a6fa05d90..5f59a9d60 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -390,14 +390,13 @@ class HelpChannels(Scheduler, commands.Cog): log.info("Cog is ready!") self.ready.set() - @staticmethod - def is_dormant_message(message: t.Optional[discord.Message]) -> bool: + 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: return False embed = message.embeds[0] - return embed.description.strip() == DORMANT_MSG.strip() + return message.author == self.bot.user and embed.description.strip() == DORMANT_MSG.strip() async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None: """ -- cgit v1.2.3 From 65aba04130533154cf9cb3ebb64414f41d987305 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 9 Apr 2020 17:48:31 +0200 Subject: Reverse order of moving to dormant and task cancellation. Reversing the order ensures the task is not cancelled when moving to dormant fails which gives a fallback to move it after the initial period of time. --- 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 5f59a9d60..69812eda0 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -217,8 +217,8 @@ class HelpChannels(Scheduler, commands.Cog): with suppress(discord.errors.HTTPException, discord.errors.NotFound): await self.reset_claimant_send_permission(ctx.channel) - self.cancel_task(ctx.channel.id) await self.move_to_dormant(ctx.channel) + self.cancel_task(ctx.channel.id) else: log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") -- cgit v1.2.3 From d0a9404cb0cfee64bf26df5ec7cce2ea71cb4f15 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 9 Apr 2020 18:02:30 +0200 Subject: Delete channel from claimant cache. Deleting the channel from the claimant cache on invokation of the dormant command prevents users running the command multiple times before the bot moves it. --- bot/cogs/help_channels.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 69812eda0..346d35aa8 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -210,6 +210,9 @@ class HelpChannels(Scheduler, commands.Cog): log.trace("dormant command invoked; checking if the channel is in-use.") if ctx.channel.category == self.in_use_category: if await self.dormant_check(ctx): + with suppress(KeyError): + del self.help_channel_claimants[ctx.channel] + with suppress(discord.errors.NotFound): log.trace("Deleting dormant invokation message.") await ctx.message.delete() -- cgit v1.2.3 From 4ac17f806c2f9d98503067dbd181ae2a839bc74c Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 9 Apr 2020 18:31:55 +0200 Subject: Specify encoding to logging file handler. With the new addition of non latin-11 chars in channel names - which get logged, the logging to files fails on those entries on OSs where the default encoding is not utf8 or an other encoding capable of handling them. --- bot/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/__init__.py b/bot/__init__.py index c9dbc3f40..2dd4af225 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -33,7 +33,7 @@ log_format = logging.Formatter(format_string) log_file = Path("logs", "bot.log") log_file.parent.mkdir(exist_ok=True) -file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7) +file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7, encoding="utf8") file_handler.setFormatter(log_format) root_log = logging.getLogger() -- cgit v1.2.3 From 3eecb147d6f788c83b230e9f79edf44da4d5c621 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Fri, 10 Apr 2020 15:38:19 +0200 Subject: Use synchronized permission reset. --- 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 346d35aa8..daadcc9a4 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -635,7 +635,7 @@ class HelpChannels(Scheduler, commands.Cog): return log.trace(f"Resetting send permissions for {member} ({member.id}).") - await self.available_category.set_permissions(member, overwrite=None) + await self.update_category_permissions(self.available_category, member, overwrite=None) # Ignore missing task when claim cooldown has passed but the channel still isn't dormant. self.cancel_task(member.id, ignore_missing=True) -- cgit v1.2.3 From d8e54d9921d204a0a95118136982186c790e0dd8 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Fri, 10 Apr 2020 15:43:34 +0200 Subject: Fix `help_channel_claimants` typehint. `ctx.author` that is used to populate the dict returns a `Member` object in most cases while only `User` was documented as a possible value. --- bot/cogs/help_channels.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index daadcc9a4..4404ecc17 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -109,7 +109,9 @@ class HelpChannels(Scheduler, commands.Cog): super().__init__() self.bot = bot - self.help_channel_claimants: t.Dict[discord.TextChannel, discord.User] = {} + self.help_channel_claimants: ( + t.Dict[discord.TextChannel, t.Union[discord.Member, discord.User]] + ) = {} # Categories self.available_category: discord.CategoryChannel = None -- cgit v1.2.3 From adc75ff9bbcf8b905bd78c78f253522ae5e42fc3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 10 Apr 2020 22:43:56 -0700 Subject: Tags: explicitly use UTF-8 to read files Not all operating systems use UTF-8 as the default encoding. For systems that don't, reading tag files with Unicode would cause an unhandled exception. --- bot/cogs/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index a6e5952ff..8705d0c61 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -43,7 +43,7 @@ class Tags(Cog): tag = { "title": tag_title, "embed": { - "description": file.read_text() + "description": file.read_text(encoding="utf-8") } } cache[tag_title] = tag -- cgit v1.2.3 From ef555490e222474a48fa470f30a1e600816c465f Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Sat, 11 Apr 2020 18:14:07 +0100 Subject: StatsD integration --- Pipfile | 3 ++- Pipfile.lock | 59 ++++++++++++++++++++-------------------- bot/__main__.py | 1 + bot/bot.py | 14 ++++++++-- bot/cogs/antispam.py | 1 + bot/cogs/defcon.py | 1 + bot/cogs/error_handler.py | 2 ++ bot/cogs/filtering.py | 2 ++ bot/cogs/help_channels.py | 21 +++++++++++++-- bot/cogs/stats.py | 65 +++++++++++++++++++++++++++++++++++++++++++++ bot/cogs/tags.py | 3 +++ bot/cogs/token_remover.py | 2 ++ bot/cogs/webhook_remover.py | 2 ++ bot/constants.py | 1 + config-default.yml | 2 ++ 15 files changed, 145 insertions(+), 34 deletions(-) create mode 100644 bot/cogs/stats.py diff --git a/Pipfile b/Pipfile index 04cc98427..e7fb61957 100644 --- a/Pipfile +++ b/Pipfile @@ -19,7 +19,8 @@ requests = "~=2.22" more_itertools = "~=8.2" sentry-sdk = "~=0.14" coloredlogs = "~=14.0" -colorama = {version = "~=0.4.3", sys_platform = "== 'win32'"} +colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"} +statsd = "~=3.3" [dev-packages] coverage = "~=5.0" diff --git a/Pipfile.lock b/Pipfile.lock index ad9a3173a..19e03bda4 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "2d3ba484e8467a115126b2ba39fa5f36f103ea455477813dd658797875c79cc9" + "sha256": "10636aef5a07f17bd00608df2cc5214fcbfe3de4745cdeea7a076b871754620a" }, "pipfile-spec": 6, "requires": { @@ -87,18 +87,18 @@ }, "beautifulsoup4": { "hashes": [ - "sha256:05fd825eb01c290877657a56df4c6e4c311b3965bda790c613a3d6fb01a5462a", - "sha256:9fbb4d6e48ecd30bcacc5b63b94088192dcda178513b2ae3c394229f8911b887", - "sha256:e1505eeed31b0f4ce2dbb3bc8eb256c04cc2b3b72af7d551a4ab6efd5cbe5dae" + "sha256:594ca51a10d2b3443cbac41214e12dbb2a1cd57e1a7344659849e2e20ba6a8d8", + "sha256:a4bbe77fd30670455c5296242967a123ec28c37e9702a8a81bd2f20a4baf0368", + "sha256:d4e96ac9b0c3a6d3f0caae2e4124e6055c5dcafde8e2f831ff194c104f0775a0" ], - "version": "==4.8.2" + "version": "==4.9.0" }, "certifi": { "hashes": [ - "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", - "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" + "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304", + "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519" ], - "version": "==2019.11.28" + "version": "==2020.4.5.1" }, "cffi": { "hashes": [ @@ -167,10 +167,10 @@ }, "discord-py": { "hashes": [ - "sha256:7424be26b07b37ecad4404d9383d685995a0e0b3df3f9c645bdd3a4d977b83b4" + "sha256:406871b06d86c3dc49fba63238519f28628dac946fef8a0e22988ff58ec05580" ], "index": "pypi", - "version": "==1.3.2" + "version": "==1.3.3" }, "docutils": { "hashes": [ @@ -393,17 +393,10 @@ }, "pyparsing": { "hashes": [ - "sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f", - "sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec" + "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", + "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], - "version": "==2.4.6" - }, - "pyreadline": { - "hashes": [ - "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1" - ], - "markers": "sys_platform == 'win32'", - "version": "==2.1" + "version": "==2.4.7" }, "python-dateutil": { "hashes": [ @@ -524,6 +517,14 @@ ], "version": "==1.1.4" }, + "statsd": { + "hashes": [ + "sha256:c610fb80347fca0ef62666d241bce64184bd7cc1efe582f9690e045c25535eaa", + "sha256:e3e6db4c246f7c59003e51c9720a51a7f39a396541cb9b147ff4b14d15b5dd1f" + ], + "index": "pypi", + "version": "==3.3.0" + }, "urllib3": { "hashes": [ "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", @@ -717,11 +718,11 @@ }, "flake8-tidy-imports": { "hashes": [ - "sha256:5b6e75cec6d751e66534c522fbdce7dac1c2738b1216b0f6b10453995932e188", - "sha256:cf26fbb3ab31a398f265d53b6f711d80006450c19221e41b2b7b0e0b14ac39c5" + "sha256:62059ca07d8a4926b561d392cbab7f09ee042350214a25cf12823384a45d27dd", + "sha256:c30b40337a2e6802ba3bb611c26611154a27e94c53fc45639e3e282169574fd3" ], "index": "pypi", - "version": "==4.0.1" + "version": "==4.1.0" }, "flake8-todo": { "hashes": [ @@ -732,10 +733,10 @@ }, "identify": { "hashes": [ - "sha256:a7577a1f55cee1d21953a5cf11a3c839ab87f5ef909a4cba6cf52ed72b4c6059", - "sha256:ab246293e6585a1c6361a505b68d5b501a0409310932b7de2c2ead667b564d89" + "sha256:2bb8760d97d8df4408f4e805883dad26a2d076f04be92a10a3e43f09c6060742", + "sha256:faffea0fd8ec86bb146ac538ac350ed0c73908326426d387eded0bcc9d077522" ], - "version": "==1.4.13" + "version": "==1.4.14" }, "mccabe": { "hashes": [ @@ -835,10 +836,10 @@ }, "virtualenv": { "hashes": [ - "sha256:87831f1070534b636fea2241dd66f3afe37ac9041bcca6d0af3215cdcfbf7d82", - "sha256:f3128d882383c503003130389bf892856341c1da12c881ae24d6358c82561b55" + "sha256:00cfe8605fb97f5a59d52baab78e6070e72c12ca64f51151695407cc0eb8a431", + "sha256:c8364ec469084046c779c9a11ae6340094e8a0bf1d844330fc55c1cefe67c172" ], - "version": "==20.0.13" + "version": "==20.0.17" } } } diff --git a/bot/__main__.py b/bot/__main__.py index bf98f2cfd..2125e8590 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -57,6 +57,7 @@ bot.load_extension("bot.cogs.reminders") bot.load_extension("bot.cogs.site") bot.load_extension("bot.cogs.snekbox") bot.load_extension("bot.cogs.sync") +bot.load_extension("bot.cogs.stats") bot.load_extension("bot.cogs.tags") bot.load_extension("bot.cogs.token_remover") bot.load_extension("bot.cogs.utils") diff --git a/bot/bot.py b/bot/bot.py index 950ac6751..65081e438 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -6,10 +6,10 @@ from typing import Optional import aiohttp import discord +import statsd from discord.ext import commands -from bot import api -from bot import constants +from bot import DEBUG_MODE, api, constants log = logging.getLogger('bot') @@ -33,6 +33,16 @@ class Bot(commands.Bot): self._resolver = None self._guild_available = asyncio.Event() + statsd_url = constants.Bot.statsd_host + + if DEBUG_MODE: + # Since statsd is UDP, there are no errors for sending to a down port. + # For this reason, setting the statsd host to 127.0.0.1 for development + # will effectively disable stats. + statsd_url = "127.0.0.1" + + self.stats = statsd.StatsClient(statsd_url, 8125, prefix="bot") + def add_cog(self, cog: commands.Cog) -> None: """Adds a "cog" to the bot and logs the operation.""" super().add_cog(cog) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index baa6b9459..d63acbc4a 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -182,6 +182,7 @@ class AntiSpam(Cog): # which contains the reason for why the message violated the rule and # an iterable of all members that violated the rule. if result is not None: + self.bot.stats.incr(f"mod_alerts.{rule_name}") reason, members, relevant_messages = result full_reason = f"`{rule_name}` rule: {reason}" diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index cc0f79fe8..80dc6082f 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -104,6 +104,7 @@ class Defcon(Cog): log.exception(f"Unable to send rejection message to user: {member}") await member.kick(reason="DEFCON active, user is too new") + self.bot.stats.incr("defcon_leaves") message = ( f"{member} (`{member.id}`) was denied entry because their account is too new." diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 6a622d2ce..747ab4a6e 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -236,6 +236,8 @@ class ErrorHandler(Cog): f"```{e.__class__.__name__}: {e}```" ) + ctx.bot.stats.incr("command_error_count") + with push_scope() as scope: scope.user = { "id": ctx.author.id, diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 3f3dbb853..fa4420be1 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -207,6 +207,8 @@ class Filtering(Cog): log.debug(message) + self.bot.stats.incr(f"bot.filters.{filter_name}") + additional_embeds = None additional_embeds_msg = None diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 697a4d3b7..389a4ad2a 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -367,6 +367,18 @@ class HelpChannels(Scheduler, commands.Cog): log.info("Cog is ready!") self.ready.set() + self.report_stats() + + def report_stats(self) -> None: + """Report the channel count stats.""" + total_in_use = len(list(self.get_category_channels(self.in_use_category))) + total_available = len(list(self.get_category_channels(self.available_category))) + total_dormant = len(list(self.get_category_channels(self.dormant_category))) + + self.bot.stats.gauge("help.total.in_use", total_in_use) + self.bot.stats.gauge("help.total.available", total_available) + self.bot.stats.gauge("help.total.dormant", total_dormant) + @staticmethod def is_dormant_message(message: t.Optional[discord.Message]) -> bool: """Return True if the contents of the `message` match `DORMANT_MSG`.""" @@ -432,6 +444,7 @@ class HelpChannels(Scheduler, commands.Cog): f"synchronized permissions after moving `{channel}` into it." ) await self.ensure_permissions_synchronization(self.available_category) + self.report_stats() async def move_to_dormant(self, channel: discord.TextChannel) -> None: """Make the `channel` dormant.""" @@ -442,7 +455,6 @@ class HelpChannels(Scheduler, commands.Cog): category=self.dormant_category, sync_permissions=True, topic=DORMANT_TOPIC, - position=10000, ) log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.") @@ -453,6 +465,7 @@ class HelpChannels(Scheduler, commands.Cog): log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") self.channel_queue.put_nowait(channel) + self.report_stats() async def move_to_in_use(self, channel: discord.TextChannel) -> None: """Make a channel in-use and schedule it to be made dormant.""" @@ -463,7 +476,6 @@ class HelpChannels(Scheduler, commands.Cog): category=self.in_use_category, sync_permissions=True, topic=IN_USE_TOPIC, - position=10000, ) timeout = constants.HelpChannels.idle_minutes * 60 @@ -471,6 +483,7 @@ class HelpChannels(Scheduler, commands.Cog): log.trace(f"Scheduling #{channel} ({channel.id}) to become dormant in {timeout} sec.") data = TaskData(timeout, self.move_idle_channel(channel)) self.schedule_task(channel.id, data) + self.report_stats() async def notify(self) -> None: """ @@ -511,6 +524,8 @@ class HelpChannels(Scheduler, commands.Cog): f"using the `{constants.Bot.prefix}dormant` command within the channels." ) + self.bot.stats.incr("help.out_of_channel_alerts") + self.last_notification = message.created_at except Exception: # Handle it here cause this feature isn't critical for the functionality of the system. @@ -543,6 +558,8 @@ class HelpChannels(Scheduler, commands.Cog): await self.move_to_in_use(channel) await self.revoke_send_permissions(message.author) + self.bot.stats.incr("help.claimed") + log.trace(f"Releasing on_message lock for {message.id}.") # Move a dormant channel to the Available category to fill in the gap. diff --git a/bot/cogs/stats.py b/bot/cogs/stats.py new file mode 100644 index 000000000..b75d29b7e --- /dev/null +++ b/bot/cogs/stats.py @@ -0,0 +1,65 @@ +from discord import Member, Message, Status +from discord.ext.commands import Bot, Cog, Context + + +class Stats(Cog): + """A cog which provides a way to hook onto Discord events and forward to stats.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @Cog.listener() + async def on_message(self, message: Message) -> None: + """Report message events in the server to statsd.""" + if message.guild is None: + return + + reformatted_name = message.channel.name.replace('-', '_') + + if reformatted_name.startswith("ot"): + # Off-topic channels change names, we don't want this for stats. + # This will change 'ot1-lemon-in-the-dishwasher' to just 'ot1' + reformatted_name = reformatted_name[:3] + + stat_name = f"channels.{reformatted_name}" + self.bot.stats.incr(stat_name) + + # Increment the total message count + self.bot.stats.incr("messages") + + @Cog.listener() + async def on_command_completion(self, ctx: Context) -> None: + """Report completed commands to statsd.""" + command_name = ctx.command.qualified_name.replace(" ", "_") + + self.bot.stats.incr(f"commands.{command_name}") + + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + """Update member count stat on member join.""" + self.bot.stats.gauge(f"guild.total_members", len(member.guild.members)) + + @Cog.listener() + async def on_member_leave(self, member: Member) -> None: + """Update member count stat on member leave.""" + self.bot.stats.gauge(f"guild.total_members", len(member.guild.members)) + + @Cog.listener() + async def on_member_update(self, _before: Member, after: Member) -> None: + """Update presence estimates on member update.""" + members = after.guild.members + + online = len([m for m in members if m.status == Status.online]) + idle = len([m for m in members if m.status == Status.idle]) + dnd = len([m for m in members if m.status == Status.do_not_disturb]) + offline = len([m for m in members if m.status == Status.offline]) + + self.bot.stats.gauge("guild.status.online", online) + self.bot.stats.gauge("guild.status.idle", idle) + self.bot.stats.gauge("guild.status.do_not_disturb", dnd) + self.bot.stats.gauge("guild.status.offline", offline) + + +def setup(bot: Bot) -> None: + """Load the stats cog.""" + bot.add_cog(Stats(bot)) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index a6e5952ff..b81859db1 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -207,6 +207,9 @@ class Tags(Cog): "time": time.time(), "channel": ctx.channel.id } + + self.bot.stats.incr(f"tags.usages.{tag_name.replace('-', '_')}") + await wait_for_deletion( await ctx.send(embed=Embed.from_dict(tag['embed'])), [ctx.author.id], diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 421ad23e2..6721f0e02 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -93,6 +93,8 @@ class TokenRemover(Cog): channel_id=Channels.mod_alerts, ) + self.bot.stats.incr("tokens.removed_tokens") + @classmethod def find_token_in_message(cls, msg: Message) -> t.Optional[str]: """Return a seemingly valid token found in `msg` or `None` if no token is found.""" diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index 49692113d..1b5c3f821 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -54,6 +54,8 @@ class WebhookRemover(Cog): channel_id=Channels.mod_alerts ) + self.bot.stats.incr("tokens.removed_webhooks") + @Cog.listener() async def on_message(self, msg: Message) -> None: """Check if a Discord webhook URL is in `message`.""" diff --git a/bot/constants.py b/bot/constants.py index 60e3c4897..33c1d530d 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -199,6 +199,7 @@ class Bot(metaclass=YAMLGetter): prefix: str token: str sentry_dsn: str + statsd_host: str class Filter(metaclass=YAMLGetter): section = "filter" diff --git a/config-default.yml b/config-default.yml index 896003973..567caacbf 100644 --- a/config-default.yml +++ b/config-default.yml @@ -3,6 +3,8 @@ bot: token: !ENV "BOT_TOKEN" sentry_dsn: !ENV "BOT_SENTRY_DSN" + statsd_host: "graphite" + cooldowns: # Per channel, per tag. tags: 60 -- cgit v1.2.3 From 100a903d6604ac019adff0d4e197a092be2f273f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 11 Apr 2020 10:28:47 -0700 Subject: HelpChannels: create a helper method for checking a chann --- bot/cogs/help_channels.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 797019f69..d91f3f91f 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -521,7 +521,8 @@ class HelpChannels(Scheduler, commands.Cog): return # Ignore messages sent by bots. channel = message.channel - if channel.category and channel.category.id != constants.Categories.help_available: + category = getattr(channel, "category", None) + if category and category.id != constants.Categories.help_available: return # Ignore messages outside the Available category. log.trace("Waiting for the cog to be ready before processing messages.") @@ -531,7 +532,8 @@ class HelpChannels(Scheduler, commands.Cog): async with self.on_message_lock: log.trace(f"on_message lock acquired for {message.id}.") - if channel.category and channel.category.id != constants.Categories.help_available: + category = getattr(channel, "category", None) + if category and category.id != constants.Categories.help_available: log.debug( f"Message {message.id} will not make #{channel} ({channel.id}) in-use " f"because another message in the channel already triggered that." -- cgit v1.2.3 From 8249ea49144123a81f33163e266691e465c6fd77 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 11 Apr 2020 10:37:43 -0700 Subject: HelpChannels: create helper method for checking channel's category --- bot/cogs/help_channels.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index d91f3f91f..56caa60af 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -376,6 +376,12 @@ class HelpChannels(Scheduler, commands.Cog): embed = message.embeds[0] return embed.description.strip() == DORMANT_MSG.strip() + @staticmethod + def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: + """Return True if `channel` is within a category with `category_id`.""" + actual_category = getattr(channel, "category", None) + return actual_category and actual_category.id == category_id + async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None: """ Make the `channel` dormant if idle or schedule the move if still active. @@ -521,8 +527,7 @@ class HelpChannels(Scheduler, commands.Cog): return # Ignore messages sent by bots. channel = message.channel - category = getattr(channel, "category", None) - if category and category.id != constants.Categories.help_available: + if not self.is_in_category(channel, constants.Categories.help_available): return # Ignore messages outside the Available category. log.trace("Waiting for the cog to be ready before processing messages.") @@ -532,8 +537,7 @@ class HelpChannels(Scheduler, commands.Cog): async with self.on_message_lock: log.trace(f"on_message lock acquired for {message.id}.") - category = getattr(channel, "category", None) - if category and category.id != constants.Categories.help_available: + if not self.is_in_category(channel, constants.Categories.help_available): log.debug( f"Message {message.id} will not make #{channel} ({channel.id}) in-use " f"because another message in the channel already triggered that." -- cgit v1.2.3 From bc17ec3f60b061ce93f627c1f69182f655d4fc37 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Sat, 11 Apr 2020 19:18:31 +0100 Subject: Address review comments from Mark --- bot.svg | 3795 +++++++++++++++++++++++++++++++++++++++++++++ bot/__main__.py | 4 +- bot/cogs/defcon.py | 2 +- bot/cogs/error_handler.py | 2 +- bot/cogs/filtering.py | 2 +- bot/cogs/stats.py | 46 +- 6 files changed, 3837 insertions(+), 14 deletions(-) create mode 100644 bot.svg diff --git a/bot.svg b/bot.svg new file mode 100644 index 000000000..97a3914d4 --- /dev/null +++ b/bot.svg @@ -0,0 +1,3795 @@ + + + + + + +G + + + +bot_cogs_clean + +bot.cogs.clean + + + +requests + +requests + + + +discord_Webhook + +discord. +Webhook + + + +requests->discord_Webhook + + + + + +bot_cogs_doc + +bot.cogs.doc + + + +requests->bot_cogs_doc + + + + + +bot_bot + +bot.bot + + + +bot_bot->bot_cogs_clean + + + + + +bot_cogs_moderation_management + +bot. +cogs. +moderation. +management + + + +bot_bot->bot_cogs_moderation_management + + + + + +bot_cogs_webhook_remover + +bot. +cogs. +webhook_remover + + + +bot_bot->bot_cogs_webhook_remover + + + + + + + +bot_cogs_verification + +bot. +cogs. +verification + + + +bot_bot->bot_cogs_verification + + + + + +bot_cogs_wolfram + +bot. +cogs. +wolfram + + + +bot_bot->bot_cogs_wolfram + + + + + + +bot_cogs_site + +bot.cogs.site + + + +bot_bot->bot_cogs_site + + + + + +bot_cogs_watchchannels_watchchannel + +bot. +cogs. +watchchannels. +watchchannel + + + +bot_bot->bot_cogs_watchchannels_watchchannel + + + + + + +bot_cogs_extensions + +bot. +cogs. +extensions + + + +bot_bot->bot_cogs_extensions + + + + + +bot_cogs_antimalware + +bot. +cogs. +antimalware + + + +bot_bot->bot_cogs_antimalware + + + + + +bot___main__ + +bot.__main__ + + + +bot_bot->bot___main__ + + + + + +bot_cogs_moderation + +bot. +cogs. +moderation + + + +bot_bot->bot_cogs_moderation + + + + + +bot_cogs_duck_pond + +bot. +cogs. +duck_pond + + + +bot_bot->bot_cogs_duck_pond + + + + + + +bot_cogs_antispam + +bot. +cogs. +antispam + + + +bot_bot->bot_cogs_antispam + + + + + +bot_interpreter + +bot. +interpreter + + + +bot_bot->bot_interpreter + + + + + +bot_cogs_moderation_superstarify + +bot. +cogs. +moderation. +superstarify + + + +bot_bot->bot_cogs_moderation_superstarify + + + + +bot_cogs_defcon + +bot. +cogs. +defcon + + + +bot_bot->bot_cogs_defcon + + + + + + +bot_cogs_moderation_ModLog + +bot. +cogs. +moderation. +ModLog + + + +bot_bot->bot_cogs_moderation_ModLog + + + + + +bot_cogs_watchchannels_talentpool + +bot. +cogs. +watchchannels. +talentpool + + + +bot_bot->bot_cogs_watchchannels_talentpool + + + + +bot_cogs_information + +bot. +cogs. +information + + + +bot_bot->bot_cogs_information + + + + + +bot_cogs_off_topic_names + +bot. +cogs. +off_topic_names + + + +bot_bot->bot_cogs_off_topic_names + + + + + + +bot_cogs_alias + +bot.cogs.alias + + + +bot_bot->bot_cogs_alias + + + + + + +bot_cogs_moderation_silence + +bot. +cogs. +moderation. +silence + + + +bot_bot->bot_cogs_moderation_silence + + + + + +bot_cogs_sync_syncers + +bot. +cogs. +sync. +syncers + + + +bot_bot->bot_cogs_sync_syncers + + + + + + +bot_cogs_moderation_scheduler + +bot. +cogs. +moderation. +scheduler + + + +bot_bot->bot_cogs_moderation_scheduler + + + + + + +bot_cogs_eval + +bot.cogs.eval + + + +bot_bot->bot_cogs_eval + + + + + +bot_cogs_help_channels + +bot. +cogs. +help_channels + + + +bot_bot->bot_cogs_help_channels + + + + + +bot_cogs_tags + +bot.cogs.tags + + + +bot_bot->bot_cogs_tags + + + + + + +bot_cogs_reddit + +bot. +cogs. +reddit + + + +bot_bot->bot_cogs_reddit + + + + + + +bot_cogs_utils + +bot.cogs.utils + + + +bot_bot->bot_cogs_utils + + + + + +bot_cogs_sync + +bot.cogs.sync + + + +bot_bot->bot_cogs_sync + + + + + +bot_cogs_help + +bot.cogs.help + + + +bot_bot->bot_cogs_help + + + + + +bot_cogs_bot + +bot.cogs.bot + + + +bot_bot->bot_cogs_bot + + + + + + + + +bot_cogs_watchchannels_bigbrother + +bot. +cogs. +watchchannels. +bigbrother + + + +bot_bot->bot_cogs_watchchannels_bigbrother + + + + +bot_cogs_logging + +bot. +cogs. +logging + + + +bot_bot->bot_cogs_logging + + + + + + +bot_cogs_config_verifier + +bot. +cogs. +config_verifier + + + +bot_bot->bot_cogs_config_verifier + + + + + + +bot_cogs_moderation_modlog + +bot. +cogs. +moderation. +modlog + + + +bot_bot->bot_cogs_moderation_modlog + + + + + + +bot_cogs_sync_cog + +bot. +cogs. +sync. +cog + + + +bot_bot->bot_cogs_sync_cog + + + + + + +bot_cogs_snekbox + +bot. +cogs. +snekbox + + + +bot_bot->bot_cogs_snekbox + + + + + +bot_cogs_moderation_infractions + +bot. +cogs. +moderation. +infractions + + + +bot_bot->bot_cogs_moderation_infractions + + + + + +bot_cogs_watchchannels + +bot. +cogs. +watchchannels + + + +bot_bot->bot_cogs_watchchannels + + + + + +bot_bot->bot_cogs_doc + + + + + +bot_cogs_security + +bot. +cogs. +security + + + +bot_bot->bot_cogs_security + + + + + +bot_cogs_reminders + +bot. +cogs. +reminders + + + +bot_bot->bot_cogs_reminders + + + + + +bot_cogs_token_remover + +bot. +cogs. +token_remover + + + +bot_bot->bot_cogs_token_remover + + + + + + +bot_cogs_filtering + +bot. +cogs. +filtering + + + +bot_bot->bot_cogs_filtering + + + + + +bot_cogs_jams + +bot.cogs.jams + + + +bot_bot->bot_cogs_jams + + + + + +bot_cogs_error_handler + +bot. +cogs. +error_handler + + + +bot_bot->bot_cogs_error_handler + + + + + +bot_rules_attachments + +bot. +rules. +attachments + + + +bot_rules + +bot.rules + + + +bot_rules_attachments->bot_rules + + + + + +bot_cogs_stats + +bot.cogs.stats + + + +bot_api + +bot.api + + + +bot_api->bot_bot + + + + + +bot_api->bot_cogs_watchchannels_watchchannel + + + + +bot_cogs_moderation_utils + +bot. +cogs. +moderation. +utils + + + +bot_api->bot_cogs_moderation_utils + + + + + +bot_api->bot_cogs_watchchannels_talentpool + + + + +bot_api->bot_cogs_off_topic_names + + + + + +bot_api->bot_cogs_sync_syncers + + + + + +bot_api->bot_cogs_moderation_scheduler + + + + +bot_api->bot_cogs_sync_cog + + + + + +bot_api->bot_cogs_error_handler + + + + + + + +bot_cogs_moderation_management->bot_cogs_moderation + + + + + +urllib3_exceptions + +urllib3. +exceptions + + + +urllib3_exceptions->requests + + + + + +urllib3 + +urllib3 + + + +urllib3_exceptions->urllib3 + + + + + +urllib3_exceptions->bot_cogs_doc + + + + + +dateutil_parser + +dateutil. +parser + + + +bot_converters + +bot.converters + + + +dateutil_parser->bot_converters + + + + + +dateutil_parser->bot_cogs_watchchannels_watchchannel + + + + + + +bot_utils_time + +bot.utils.time + + + +dateutil_parser->bot_utils_time + + + + + +dateutil_parser->bot_cogs_moderation_scheduler + + + + + + +dateutil_parser->bot_cogs_reminders + + + + +dateutil_relativedelta + +dateutil. +relativedelta + + + +dateutil_relativedelta->bot_cogs_wolfram + + + + + + +dateutil_relativedelta->bot_converters + + + + + + +dateutil_relativedelta->bot_cogs_moderation_ModLog + + + + + + +dateutil_relativedelta->bot_utils_time + + + + + +dateutil_relativedelta->bot_cogs_utils + + + + + +dateutil_relativedelta->bot_cogs_moderation_modlog + + + + + +dateutil_relativedelta->bot_cogs_reminders + + + + +dateutil_relativedelta->bot_cogs_filtering + + + + + + +bot_rules_chars + +bot. +rules. +chars + + + +bot_rules_chars->bot_rules + + + + + +discord_Guild + +discord.Guild + + + +discord_Guild->bot_cogs_sync_syncers + + + + + +bot_converters->bot_cogs_moderation_management + + + + +bot_converters->bot_cogs_antispam + + + + + +bot_converters->bot_cogs_moderation_superstarify + + + + +bot_converters->bot_cogs_watchchannels_talentpool + + + + + + +bot_converters->bot_cogs_alias + + + + + +bot_converters->bot_cogs_moderation_silence + + + + +bot_converters->bot_cogs_tags + + + + + +bot_converters->bot_cogs_reddit + + + + + +bot_converters->bot_cogs_watchchannels_bigbrother + + + + +bot_converters->bot_cogs_moderation_infractions + + + + + +bot_converters->bot_cogs_doc + + + + + +bot_converters->bot_cogs_reminders + + + + +bot_converters->bot_cogs_error_handler + + + + + +bs4 + +bs4 + + + +bs4->bot_cogs_doc + + + + + +discord_Client + +discord.Client + + + +bot_utils_messages + +bot. +utils. +messages + + + +discord_Client->bot_utils_messages + + + + +bot_rules_burst_shared + +bot. +rules. +burst_shared + + + +bot_rules_burst_shared->bot_rules + + + + + +bot_cogs_watchchannels_watchchannel->bot_cogs_watchchannels_talentpool + + + + + +bot_cogs_watchchannels_watchchannel->bot_cogs_watchchannels_bigbrother + + + + + +aiohttp + +aiohttp + + + +aiohttp->bot_bot + + + + + +aiohttp->bot_api + + + + + + +aiohttp->bot_converters + + + + + +aiohttp->discord_Client + + + + + + +aiohttp->discord_Webhook + + + + + +aiohttp->bot_cogs_reddit + + + + + +bot_cogs_extensions->bot_cogs_alias + + + + + +discord_User + +discord.User + + + +discord_User->bot_cogs_clean + + + + + + +discord_User->bot_cogs_duck_pond + + + + + + + +discord_User->bot_cogs_sync_syncers + + + + + +discord_User->bot_cogs_help + + + + + +discord_User->bot_cogs_sync_cog + + + + + +discord_User->bot_cogs_snekbox + + + + + +bot_rules->bot_cogs_antispam + + + + + +bot_cogs_moderation->bot_cogs_clean + + + + + +bot_cogs_moderation->bot_cogs_webhook_remover + + + + + +bot_cogs_moderation->bot_cogs_verification + + + + + +bot_cogs_moderation->bot_cogs_watchchannels_watchchannel + + + + + +bot_cogs_moderation->bot_cogs_antispam + + + + + +bot_cogs_moderation->bot_cogs_defcon + + + + + +bot_cogs_moderation->bot_cogs_watchchannels_bigbrother + + + + +bot_cogs_moderation->bot_cogs_token_remover + + + + + +bot_cogs_moderation->bot_cogs_filtering + + + + + +discord + +discord + + + +discord->bot_cogs_clean + + + + + +discord->bot_bot + + + + + +discord->bot_rules_attachments + + + + + +discord->bot_cogs_stats + + + + + +discord->bot_cogs_moderation_management + + + + + +discord->bot_cogs_webhook_remover + + + + +discord->bot_cogs_verification + + + + + + +discord->bot_cogs_wolfram + + + + + + + +discord->bot_rules_chars + + + + + +discord->bot_converters + + + + + +discord->bot_cogs_site + + + + + + +discord->bot_rules_burst_shared + + + + + +discord->bot_cogs_watchchannels_watchchannel + + + + + +discord->bot_cogs_extensions + + + + + +discord->bot_cogs_antimalware + + + + + +discord->bot___main__ + + + + + + +discord->bot_cogs_duck_pond + + + + + +discord->bot_cogs_antispam + + + + +discord->bot_interpreter + + + + + +discord->bot_cogs_moderation_superstarify + + + + + +discord->bot_cogs_defcon + + + + + + +bot_pagination + +bot.pagination + + + +discord->bot_pagination + + + + + +discord->bot_cogs_moderation_ModLog + + + + + + +discord->bot_cogs_moderation_utils + + + + + + +bot_rules_newlines + +bot. +rules. +newlines + + + +discord->bot_rules_newlines + + + + + + +discord->bot_cogs_watchchannels_talentpool + + + + + +discord->bot_cogs_information + + + + + +discord->bot_cogs_off_topic_names + + + + + +discord->bot_cogs_alias + + + + + +discord->bot_cogs_moderation_silence + + + + + + +discord->bot_cogs_sync_syncers + + + + + + +discord->bot_cogs_moderation_scheduler + + + + + + +discord->bot_cogs_eval + + + + + + +bot_rules_burst + +bot. +rules. +burst + + + +discord->bot_rules_burst + + + + + +discord->bot_cogs_help_channels + + + + + + +discord->bot_cogs_tags + + + + + +discord->bot_cogs_reddit + + + + + + +discord->bot_cogs_utils + + + + + +bot_rules_discord_emojis + +bot. +rules. +discord_emojis + + + +discord->bot_rules_discord_emojis + + + + + +bot_rules_duplicates + +bot. +rules. +duplicates + + + +discord->bot_rules_duplicates + + + + + +discord->bot_cogs_help + + + + + +discord->bot_cogs_bot + + + + + + + +discord->bot_cogs_logging + + + + + + +bot_rules_mentions + +bot. +rules. +mentions + + + +discord->bot_rules_mentions + + + + + +bot_decorators + +bot.decorators + + + +discord->bot_decorators + + + + + +discord->bot_cogs_moderation_modlog + + + + + +discord->bot_cogs_sync_cog + + + + + +discord->bot_cogs_snekbox + + + + + +discord->bot_cogs_moderation_infractions + + + + + + +bot_rules_links + +bot. +rules. +links + + + +discord->bot_rules_links + + + + + + +discord->bot_cogs_doc + + + + + +discord->bot_utils_messages + + + + + +bot_patches_message_edited_at + +bot. +patches. +message_edited_at + + + +discord->bot_patches_message_edited_at + + + + + + + +discord->bot_cogs_reminders + + + + + +bot_rules_role_mentions + +bot. +rules. +role_mentions + + + +discord->bot_rules_role_mentions + + + + + +discord->bot_cogs_token_remover + + + + + +discord->bot_cogs_filtering + + + + + +discord->bot_cogs_jams + + + + + +bot_constants + +bot.constants + + + +bot_constants->bot_cogs_clean + + + + + +bot_constants->bot_bot + + + + + +bot_constants->bot_api + + + + + +bot_constants->bot_cogs_moderation_management + + + + + +bot_constants->bot_cogs_webhook_remover + + + + +bot_constants->bot_cogs_verification + + + + + +bot_constants->bot_cogs_wolfram + + + + +bot_constants->bot_cogs_site + + + + + + +bot_constants->bot_cogs_watchchannels_watchchannel + + + + + +bot_constants->bot_cogs_extensions + + + + + +bot_constants->bot_cogs_antimalware + + + + + +bot_constants->bot___main__ + + + + + +bot_constants->bot_cogs_duck_pond + + + + + + +bot_constants->bot_cogs_antispam + + + + + +bot_constants->bot_cogs_moderation_superstarify + + + + +bot_constants->bot_cogs_defcon + + + + + +bot_constants->bot_pagination + + + + + + +bot_constants->bot_cogs_moderation_ModLog + + + + + + +bot_constants->bot_cogs_moderation_utils + + + + + +bot_constants->bot_cogs_watchchannels_talentpool + + + + + + +bot_constants->bot_cogs_information + + + + + + +bot_constants->bot_cogs_off_topic_names + + + + + +bot_constants->bot_cogs_moderation_silence + + + + + + +bot_constants->bot_cogs_sync_syncers + + + + + +bot_constants->bot_cogs_moderation_scheduler + + + + + + +bot_constants->bot_cogs_eval + + + + + +bot_constants->bot_cogs_help_channels + + + + + +bot_constants->bot_cogs_tags + + + + + + +bot_constants->bot_cogs_reddit + + + + +bot_constants->bot_cogs_utils + + + + + +bot_constants->bot_cogs_help + + + + + + + +bot_constants->bot_cogs_bot + + + + + +bot_constants->bot_cogs_watchchannels_bigbrother + + + + + + +bot_constants->bot_cogs_logging + + + + + + +bot_constants->bot_cogs_config_verifier + + + + + +bot_constants->bot_decorators + + + + +bot_constants->bot_cogs_moderation_modlog + + + + + + +bot_constants->bot_cogs_sync_cog + + + + + + +bot_constants->bot_cogs_snekbox + + + + + + +bot_constants->bot_cogs_moderation_infractions + + + + + + +bot_constants->bot_cogs_doc + + + + + +bot_constants->bot_utils_messages + + + + + + +bot_constants->bot_cogs_reminders + + + + +bot_constants->bot_cogs_token_remover + + + + + +bot_constants->bot_cogs_filtering + + + + + + + + +bot_constants->bot_cogs_jams + + + + + +bot_constants->bot_cogs_error_handler + + + + +discord_File + +discord.File + + + +discord_File->bot_utils_messages + + + + +discord_Reaction + +discord. +Reaction + + + +discord_Reaction->bot_cogs_sync_syncers + + + + + +discord_Reaction->bot_cogs_help + + + + + +discord_Reaction->bot_cogs_snekbox + + + + + +discord_Reaction->bot_utils_messages + + + + + +bot_interpreter->bot_cogs_eval + + + + + + +bot_cogs_moderation_superstarify->bot_cogs_moderation + + + + + + +more_itertools + +more_itertools + + + +more_itertools->bot_cogs_jams + + + + + +statsd + +statsd + + + +statsd->bot_bot + + + + + +bot_pagination->bot_cogs_moderation_management + + + + + + + +bot_pagination->bot_cogs_wolfram + + + + + +bot_pagination->bot_cogs_site + + + + + +bot_pagination->bot_cogs_watchchannels_watchchannel + + + + + +bot_pagination->bot_cogs_extensions + + + + + +bot_pagination->bot_cogs_watchchannels_talentpool + + + + + +bot_pagination->bot_cogs_information + + + + + +bot_pagination->bot_cogs_off_topic_names + + + + + + +bot_pagination->bot_cogs_alias + + + + + +bot_pagination->bot_cogs_tags + + + + + +bot_pagination->bot_cogs_reddit + + + + + + +bot_pagination->bot_cogs_help + + + + + +bot_pagination->bot_cogs_doc + + + + + +bot_pagination->bot_cogs_reminders + + + + + +bot_utils_checks + +bot. +utils. +checks + + + +bot_utils_checks->bot_cogs_moderation_management + + + + + +bot_utils_checks->bot_cogs_verification + + + + + +bot_utils_checks->bot_cogs_extensions + + + + + + +bot_utils_checks->bot_cogs_moderation_superstarify + + + + + +bot_utils_checks->bot_cogs_information + + + + + +bot_utils_checks->bot_cogs_moderation_silence + + + + + + +bot_utils_checks->bot_decorators + + + + + +bot_utils_checks->bot_cogs_moderation_infractions + + + + + + +bot_utils_checks->bot_cogs_reminders + + + + +bot_cogs_moderation_ModLog->bot_cogs_clean + + + + + + +bot_cogs_moderation_ModLog->bot_cogs_verification + + + + + +bot_cogs_moderation_ModLog->bot_cogs_watchchannels_watchchannel + + + + + +bot_cogs_moderation_ModLog->bot_cogs_antispam + + + + + +bot_cogs_moderation_ModLog->bot_cogs_defcon + + + + + + +bot_cogs_moderation_ModLog->bot_cogs_token_remover + + + + + + + +bot_cogs_moderation_ModLog->bot_cogs_filtering + + + + + + + +discord_Object + +discord.Object + + + +discord_Object->bot_cogs_verification + + + + + +discord_Object->bot_cogs_antispam + + + + + + +bot_cogs_moderation_utils->bot_cogs_moderation_management + + + + + +bot_cogs_moderation_utils->bot_cogs_moderation_superstarify + + + + + + +bot_cogs_moderation_utils->bot_cogs_moderation_scheduler + + + + + +bot_cogs_moderation_utils->bot_cogs_watchchannels_bigbrother + + + + + + + +bot_cogs_moderation_utils->bot_cogs_moderation_infractions + + + + + +discord_Webhook->bot_utils_messages + + + + + + + +bot_rules_newlines->bot_rules + + + + + +bot_cogs_watchchannels_talentpool->bot_cogs_watchchannels + + + + + +discord_utils + +discord.utils + + + +discord_utils->discord_Guild + + + + + +discord_utils->discord_Client + + + + +discord_utils->discord_User + + + + + +discord_utils->discord + + + + + +discord_utils->bot_cogs_moderation_ModLog + + + + + + +discord_utils->discord_Object + + + + + +discord_utils->discord_Webhook + + + + + +discord_utils->bot_cogs_information + + + + + + +discord_Message + +discord. +Message + + + +discord_utils->discord_Message + + + + + +discord_abc + +discord.abc + + + +discord_utils->discord_abc + + + + + +discord_message + +discord. +message + + + +discord_utils->discord_message + + + + + +discord_Role + +discord.Role + + + +discord_utils->discord_Role + + + + + +discord_Member + +discord.Member + + + +discord_utils->discord_Member + + + + + + +discord_utils->bot_cogs_moderation_modlog + + + + + +discord_utils->bot_patches_message_edited_at + + + + + +discord_utils->bot_cogs_token_remover + + + + + +discord_utils->bot_cogs_filtering + + + + + + +discord_utils->bot_cogs_jams + + + + + +bot_cogs_moderation_silence->bot_cogs_moderation + + + + + +bot_utils_time->bot_cogs_moderation_management + + + + + + +bot_utils_time->bot_cogs_wolfram + + + + + +bot_utils_time->bot_cogs_watchchannels_watchchannel + + + + + +bot_utils_time->bot_cogs_moderation_superstarify + + + + + +bot_utils_time->bot_cogs_moderation_ModLog + + + + + +bot_utils_time->bot_cogs_watchchannels_talentpool + + + + +bot_utils_time->bot_cogs_information + + + + + + +bot_utils_time->bot_cogs_moderation_scheduler + + + + + +bot_utils_time->bot_cogs_utils + + + + + +bot_utils_time->bot_cogs_moderation_modlog + + + + + + + +bot_utils_time->bot_cogs_reminders + + + + + +discord_Message->bot_cogs_clean + + + + + +discord_Message->bot_rules_attachments + + + + + +discord_Message->bot_cogs_stats + + + + + +discord_Message->bot_cogs_webhook_remover + + + + + + + +discord_Message->bot_cogs_verification + + + + + +discord_Message->bot_rules_chars + + + + + + +discord_Message->bot_rules_burst_shared + + + + + +discord_Message->bot_cogs_watchchannels_watchchannel + + + + + + +discord_Message->bot_cogs_antimalware + + + + + +discord_Message->bot_cogs_duck_pond + + + + + +discord_Message->bot_cogs_antispam + + + + + +discord_Message->bot_rules_newlines + + + + + + +discord_Message->bot_cogs_information + + + + + +discord_Message->bot_cogs_sync_syncers + + + + + +discord_Message->bot_rules_burst + + + + + + + +discord_Message->bot_cogs_utils + + + + + +discord_Message->bot_rules_discord_emojis + + + + + + +discord_Message->bot_rules_duplicates + + + + + + +discord_Message->bot_cogs_help + + + + + + + + +discord_Message->bot_cogs_bot + + + + + + +discord_Message->bot_rules_mentions + + + + + + +discord_Message->bot_cogs_snekbox + + + + + + + +discord_Message->bot_rules_links + + + + + + +discord_Message->bot_utils_messages + + + + + +discord_Message->bot_rules_role_mentions + + + + + +discord_Message->bot_cogs_token_remover + + + + + + +discord_Message->bot_cogs_filtering + + + + + + + +bot_cogs_sync_syncers->bot_cogs_sync_cog + + + + + +dateutil + +dateutil + + + +dateutil->bot_cogs_wolfram + + + + + +dateutil->bot_converters + + + + + + + +dateutil->bot_cogs_watchchannels_watchchannel + + + + +dateutil->bot_cogs_moderation_ModLog + + + + + +dateutil->bot_utils_time + + + + + +dateutil->bot_cogs_moderation_scheduler + + + + + +dateutil->bot_cogs_utils + + + + + +dateutil->bot_cogs_moderation_modlog + + + + + +dateutil->bot_cogs_reminders + + + + +dateutil->bot_cogs_filtering + + + + + +bot_cogs_moderation_scheduler->bot_cogs_moderation_superstarify + + + + + +bot_cogs_moderation_scheduler->bot_cogs_moderation_infractions + + + + + +bot_rules_burst->bot_rules + + + + + +discord_abc->discord_User + + + + + +discord_abc->discord + + + + + +discord_abc->bot_pagination + + + + + +discord_abc->bot_cogs_moderation_ModLog + + + + + +discord_abc->discord_Member + + + + + +discord_abc->bot_cogs_moderation_modlog + + + + + +discord_abc->bot_utils_messages + + + + +discord_message->discord + + + + + +discord_message->discord_Webhook + + + + + +discord_message->bot_patches_message_edited_at + + + + + + +bot_rules_discord_emojis->bot_rules + + + + + +yaml + +yaml + + + +yaml->bot_constants + + + + + +bot_rules_duplicates->bot_rules + + + + + +urllib3->requests + + + + + +urllib3->bot_cogs_doc + + + + + + +discord_Role->bot_cogs_information + + + + + +discord_Role->bot_cogs_utils + + + + + +discord_Role->bot_cogs_sync_cog + + + + + +discord_Colour + +discord.Colour + + + +discord_Colour->bot_cogs_clean + + + + +discord_Colour->bot_cogs_webhook_remover + + + + + +discord_Colour->bot_cogs_verification + + + + + +discord_Colour->bot_cogs_site + + + + + + +discord_Colour->bot_cogs_extensions + + + + + +discord_Colour->bot_cogs_antispam + + + + + +discord_Colour->bot_cogs_moderation_superstarify + + + + + + +discord_Colour->bot_cogs_defcon + + + + + + +discord_Colour->bot_cogs_moderation_ModLog + + + + + + +discord_Colour->bot_cogs_information + + + + + +discord_Colour->bot_cogs_off_topic_names + + + + + +discord_Colour->bot_cogs_alias + + + + + +discord_Colour->bot_cogs_tags + + + + + +discord_Colour->bot_cogs_reddit + + + + + +discord_Colour->bot_cogs_utils + + + + + +discord_Colour->bot_cogs_help + + + + + +discord_Colour->bot_decorators + + + + + +discord_Colour->bot_cogs_moderation_modlog + + + + + + +discord_Colour->bot_cogs_token_remover + + + + + + +discord_Colour->bot_cogs_filtering + + + + + +bot_cogs_watchchannels_bigbrother->bot_cogs_watchchannels + + + + + +discord_Member->bot_rules_attachments + + + + + + + +discord_Member->bot_cogs_stats + + + + + +discord_Member->bot_rules_chars + + + + + +discord_Member->bot_rules_burst_shared + + + + + +discord_Member->bot_cogs_duck_pond + + + + + + +discord_Member->bot_cogs_antispam + + + + +discord_Member->bot_cogs_moderation_superstarify + + + + + +discord_Member->bot_cogs_defcon + + + + + +discord_Member->bot_rules_newlines + + + + + +discord_Member->bot_cogs_watchchannels_talentpool + + + + + + +discord_Member->bot_cogs_information + + + + + +discord_Member->bot_cogs_sync_syncers + + + + + +discord_Member->bot_rules_burst + + + + + +discord_Member->bot_rules_discord_emojis + + + + + +discord_Member->bot_rules_duplicates + + + + +discord_Member->bot_rules_mentions + + + + +discord_Member->bot_decorators + + + + + +discord_Member->bot_cogs_sync_cog + + + + + +discord_Member->bot_cogs_moderation_infractions + + + + + +discord_Member->bot_rules_links + + + + + +discord_Member->bot_utils_messages + + + + + + +discord_Member->bot_rules_role_mentions + + + + + +discord_Member->bot_cogs_filtering + + + + + + + +discord_Member->bot_cogs_jams + + + + + +bot_rules_mentions->bot_rules + + + + + +bot_decorators->bot_cogs_clean + + + + + + + + +bot_decorators->bot_cogs_verification + + + + +bot_decorators->bot_cogs_defcon + + + + + +bot_decorators->bot_cogs_watchchannels_talentpool + + + + + +bot_decorators->bot_cogs_information + + + + + +bot_decorators->bot_cogs_off_topic_names + + + + + +bot_decorators->bot_cogs_eval + + + + + +bot_decorators->bot_cogs_help_channels + + + + + + +bot_decorators->bot_cogs_reddit + + + + + +bot_decorators->bot_cogs_utils + + + + + +bot_decorators->bot_cogs_help + + + + + +bot_decorators->bot_cogs_bot + + + + +bot_decorators->bot_cogs_watchchannels_bigbrother + + + + + +bot_decorators->bot_cogs_snekbox + + + + + + +bot_decorators->bot_cogs_moderation_infractions + + + + + +bot_decorators->bot_cogs_doc + + + + + +bot_decorators->bot_cogs_jams + + + + + +bot_decorators->bot_cogs_error_handler + + + + + +bot_cogs_moderation_modlog->bot_cogs_moderation_management + + + + + + +bot_cogs_moderation_modlog->bot_cogs_webhook_remover + + + + + +bot_cogs_moderation_modlog->bot_cogs_moderation + + + + +bot_cogs_moderation_modlog->bot_cogs_moderation_scheduler + + + + + +bot_patches + +bot.patches + + + +bot_patches->bot___main__ + + + + + +bot_cogs_sync_cog->bot_cogs_sync + + + + + +bot_utils + +bot.utils + + + +bot_utils->bot_cogs_moderation_management + + + + +bot_utils->bot_cogs_verification + + + + + + +bot_utils->bot_cogs_wolfram + + + + + +bot_utils->bot_cogs_watchchannels_watchchannel + + + + + + +bot_utils->bot_cogs_extensions + + + + + +bot_utils->bot_cogs_duck_pond + + + + +bot_utils->bot_cogs_antispam + + + + + + +bot_utils->bot_cogs_moderation_superstarify + + + + + +bot_utils->bot_cogs_moderation_ModLog + + + + + + +bot_utils->bot_cogs_watchchannels_talentpool + + + + + + +bot_utils->bot_cogs_information + + + + +bot_utils->bot_cogs_moderation_silence + + + + + + + +bot_utils->bot_cogs_moderation_scheduler + + + + + +bot_utils->bot_cogs_help_channels + + + + + +bot_utils->bot_cogs_tags + + + + + +bot_utils->bot_cogs_utils + + + + + +bot_utils->bot_cogs_bot + + + + + + +bot_utils->bot_decorators + + + + + +bot_utils->bot_cogs_moderation_modlog + + + + + +bot_utils->bot_cogs_snekbox + + + + + +bot_utils->bot_cogs_moderation_infractions + + + + + + + +bot_utils->bot_cogs_reminders + + + + + + +bot_cogs_moderation_infractions->bot_cogs_moderation_management + + + + + +bot_cogs_moderation_infractions->bot_cogs_moderation + + + + + +bot_rules_links->bot_rules + + + + + +discord_errors + +discord.errors + + + +discord_errors->discord_Guild + + + + + +discord_errors->discord_Client + + + + + + +discord_errors->bot_cogs_watchchannels_watchchannel + + + + +discord_errors->discord_User + + + + + + +discord_errors->discord + + + + + +discord_errors->bot_cogs_duck_pond + + + + + +discord_errors->discord_Webhook + + + + + +discord_errors->discord_utils + + + + + +discord_errors->discord_Message + + + + + + +discord_errors->discord_abc + + + + + +discord_errors->discord_message + + + + + +discord_errors->discord_Role + + + + + + +discord_errors->bot_decorators + + + + + + +discord_errors->bot_cogs_doc + + + + + + +discord_errors->bot_utils_messages + + + + + +discord_errors->bot_cogs_filtering + + + + + +bot_utils_scheduling + +bot. +utils. +scheduling + + + +bot_utils_scheduling->bot_cogs_moderation_scheduler + + + + +bot_utils_scheduling->bot_cogs_help_channels + + + + + +bot_utils_scheduling->bot_cogs_reminders + + + + + +bs4_element + +bs4.element + + + +bs4_element->bs4 + + + + + +bs4_element->bot_cogs_doc + + + + + +dateutil_tz + +dateutil.tz + + + +dateutil_tz->bot_converters + + + + +bot_utils_messages->bot_cogs_watchchannels_watchchannel + + + + + + + +bot_utils_messages->bot_cogs_duck_pond + + + + + +bot_utils_messages->bot_cogs_antispam + + + + + + +bot_utils_messages->bot_cogs_tags + + + + + +bot_utils_messages->bot_cogs_bot + + + + +bot_utils_messages->bot_cogs_snekbox + + + + +bot_patches_message_edited_at->bot_patches + + + + + +bot_rules_role_mentions->bot_rules + + + + + +bot_cogs_token_remover->bot_cogs_bot + + + + + diff --git a/bot/__main__.py b/bot/__main__.py index 2125e8590..3aa36bfc0 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -46,8 +46,8 @@ bot.load_extension("bot.cogs.verification") # Feature cogs bot.load_extension("bot.cogs.alias") bot.load_extension("bot.cogs.defcon") -bot.load_extension("bot.cogs.eval") bot.load_extension("bot.cogs.duck_pond") +bot.load_extension("bot.cogs.eval") bot.load_extension("bot.cogs.information") bot.load_extension("bot.cogs.jams") bot.load_extension("bot.cogs.moderation") @@ -56,8 +56,8 @@ bot.load_extension("bot.cogs.reddit") bot.load_extension("bot.cogs.reminders") bot.load_extension("bot.cogs.site") bot.load_extension("bot.cogs.snekbox") -bot.load_extension("bot.cogs.sync") bot.load_extension("bot.cogs.stats") +bot.load_extension("bot.cogs.sync") bot.load_extension("bot.cogs.tags") bot.load_extension("bot.cogs.token_remover") bot.load_extension("bot.cogs.utils") diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 80dc6082f..06b2f25c6 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -104,7 +104,7 @@ class Defcon(Cog): log.exception(f"Unable to send rejection message to user: {member}") await member.kick(reason="DEFCON active, user is too new") - self.bot.stats.incr("defcon_leaves") + self.bot.stats.incr("defcon.leaves") message = ( f"{member} (`{member.id}`) was denied entry because their account is too new." diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 747ab4a6e..722376cc6 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -236,7 +236,7 @@ class ErrorHandler(Cog): f"```{e.__class__.__name__}: {e}```" ) - ctx.bot.stats.incr("command_error_count") + ctx.bot.stats.incr("errors.commands") with push_scope() as scope: scope.user = { diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index fa4420be1..6a703f5a1 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -207,7 +207,7 @@ class Filtering(Cog): log.debug(message) - self.bot.stats.incr(f"bot.filters.{filter_name}") + self.bot.stats.incr(f"filters.{filter_name}") additional_embeds = None additional_embeds_msg = None diff --git a/bot/cogs/stats.py b/bot/cogs/stats.py index b75d29b7e..e963dc312 100644 --- a/bot/cogs/stats.py +++ b/bot/cogs/stats.py @@ -1,6 +1,16 @@ from discord import Member, Message, Status from discord.ext.commands import Bot, Cog, Context +from bot.constants import Guild + + +CHANNEL_NAME_OVERRIDES = { + Guild.channels.off_topic_0: "off_topic_0", + Guild.channels.off_topic_1: "off_topic_1", + Guild.channels.off_topic_2: "off_topic_2", + Guild.channels.staff_lounge: "staff_lounge" +} + class Stats(Cog): """A cog which provides a way to hook onto Discord events and forward to stats.""" @@ -14,12 +24,13 @@ class Stats(Cog): if message.guild is None: return + if message.guild.id != Guild.id: + return + reformatted_name = message.channel.name.replace('-', '_') - if reformatted_name.startswith("ot"): - # Off-topic channels change names, we don't want this for stats. - # This will change 'ot1-lemon-in-the-dishwasher' to just 'ot1' - reformatted_name = reformatted_name[:3] + if CHANNEL_NAME_OVERRIDES.get(message.channel.id): + reformatted_name = CHANNEL_NAME_OVERRIDES.get(message.channel.id) stat_name = f"channels.{reformatted_name}" self.bot.stats.incr(stat_name) @@ -37,22 +48,39 @@ class Stats(Cog): @Cog.listener() async def on_member_join(self, member: Member) -> None: """Update member count stat on member join.""" + if member.guild.id != Guild.id: + return + self.bot.stats.gauge(f"guild.total_members", len(member.guild.members)) @Cog.listener() async def on_member_leave(self, member: Member) -> None: """Update member count stat on member leave.""" + if member.guild.id != Guild.id: + return + self.bot.stats.gauge(f"guild.total_members", len(member.guild.members)) @Cog.listener() async def on_member_update(self, _before: Member, after: Member) -> None: """Update presence estimates on member update.""" - members = after.guild.members + if after.guild.id != Guild.id: + return - online = len([m for m in members if m.status == Status.online]) - idle = len([m for m in members if m.status == Status.idle]) - dnd = len([m for m in members if m.status == Status.do_not_disturb]) - offline = len([m for m in members if m.status == Status.offline]) + online = 0 + idle = 0 + dnd = 0 + offline = 0 + + for member in after.guild.members: + if member.status == Status.online: + online += 1 + elif member.status == Status.dnd: + dnd += 1 + elif member.status == Status.idle: + idle += 1 + else: + offline += 1 self.bot.stats.gauge("guild.status.online", online) self.bot.stats.gauge("guild.status.idle", idle) -- cgit v1.2.3 From 77c07fc2bdad4d7caa9dc654f62a9b1c4dfb63b2 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Sat, 11 Apr 2020 19:24:54 +0100 Subject: Address review comments from Mark --- bot.svg | 3795 ----------------------------------------------------- bot/cogs/stats.py | 10 +- 2 files changed, 5 insertions(+), 3800 deletions(-) delete mode 100644 bot.svg diff --git a/bot.svg b/bot.svg deleted file mode 100644 index 97a3914d4..000000000 --- a/bot.svg +++ /dev/null @@ -1,3795 +0,0 @@ - - - - - - -G - - - -bot_cogs_clean - -bot.cogs.clean - - - -requests - -requests - - - -discord_Webhook - -discord. -Webhook - - - -requests->discord_Webhook - - - - - -bot_cogs_doc - -bot.cogs.doc - - - -requests->bot_cogs_doc - - - - - -bot_bot - -bot.bot - - - -bot_bot->bot_cogs_clean - - - - - -bot_cogs_moderation_management - -bot. -cogs. -moderation. -management - - - -bot_bot->bot_cogs_moderation_management - - - - - -bot_cogs_webhook_remover - -bot. -cogs. -webhook_remover - - - -bot_bot->bot_cogs_webhook_remover - - - - - - - -bot_cogs_verification - -bot. -cogs. -verification - - - -bot_bot->bot_cogs_verification - - - - - -bot_cogs_wolfram - -bot. -cogs. -wolfram - - - -bot_bot->bot_cogs_wolfram - - - - - - -bot_cogs_site - -bot.cogs.site - - - -bot_bot->bot_cogs_site - - - - - -bot_cogs_watchchannels_watchchannel - -bot. -cogs. -watchchannels. -watchchannel - - - -bot_bot->bot_cogs_watchchannels_watchchannel - - - - - - -bot_cogs_extensions - -bot. -cogs. -extensions - - - -bot_bot->bot_cogs_extensions - - - - - -bot_cogs_antimalware - -bot. -cogs. -antimalware - - - -bot_bot->bot_cogs_antimalware - - - - - -bot___main__ - -bot.__main__ - - - -bot_bot->bot___main__ - - - - - -bot_cogs_moderation - -bot. -cogs. -moderation - - - -bot_bot->bot_cogs_moderation - - - - - -bot_cogs_duck_pond - -bot. -cogs. -duck_pond - - - -bot_bot->bot_cogs_duck_pond - - - - - - -bot_cogs_antispam - -bot. -cogs. -antispam - - - -bot_bot->bot_cogs_antispam - - - - - -bot_interpreter - -bot. -interpreter - - - -bot_bot->bot_interpreter - - - - - -bot_cogs_moderation_superstarify - -bot. -cogs. -moderation. -superstarify - - - -bot_bot->bot_cogs_moderation_superstarify - - - - -bot_cogs_defcon - -bot. -cogs. -defcon - - - -bot_bot->bot_cogs_defcon - - - - - - -bot_cogs_moderation_ModLog - -bot. -cogs. -moderation. -ModLog - - - -bot_bot->bot_cogs_moderation_ModLog - - - - - -bot_cogs_watchchannels_talentpool - -bot. -cogs. -watchchannels. -talentpool - - - -bot_bot->bot_cogs_watchchannels_talentpool - - - - -bot_cogs_information - -bot. -cogs. -information - - - -bot_bot->bot_cogs_information - - - - - -bot_cogs_off_topic_names - -bot. -cogs. -off_topic_names - - - -bot_bot->bot_cogs_off_topic_names - - - - - - -bot_cogs_alias - -bot.cogs.alias - - - -bot_bot->bot_cogs_alias - - - - - - -bot_cogs_moderation_silence - -bot. -cogs. -moderation. -silence - - - -bot_bot->bot_cogs_moderation_silence - - - - - -bot_cogs_sync_syncers - -bot. -cogs. -sync. -syncers - - - -bot_bot->bot_cogs_sync_syncers - - - - - - -bot_cogs_moderation_scheduler - -bot. -cogs. -moderation. -scheduler - - - -bot_bot->bot_cogs_moderation_scheduler - - - - - - -bot_cogs_eval - -bot.cogs.eval - - - -bot_bot->bot_cogs_eval - - - - - -bot_cogs_help_channels - -bot. -cogs. -help_channels - - - -bot_bot->bot_cogs_help_channels - - - - - -bot_cogs_tags - -bot.cogs.tags - - - -bot_bot->bot_cogs_tags - - - - - - -bot_cogs_reddit - -bot. -cogs. -reddit - - - -bot_bot->bot_cogs_reddit - - - - - - -bot_cogs_utils - -bot.cogs.utils - - - -bot_bot->bot_cogs_utils - - - - - -bot_cogs_sync - -bot.cogs.sync - - - -bot_bot->bot_cogs_sync - - - - - -bot_cogs_help - -bot.cogs.help - - - -bot_bot->bot_cogs_help - - - - - -bot_cogs_bot - -bot.cogs.bot - - - -bot_bot->bot_cogs_bot - - - - - - - - -bot_cogs_watchchannels_bigbrother - -bot. -cogs. -watchchannels. -bigbrother - - - -bot_bot->bot_cogs_watchchannels_bigbrother - - - - -bot_cogs_logging - -bot. -cogs. -logging - - - -bot_bot->bot_cogs_logging - - - - - - -bot_cogs_config_verifier - -bot. -cogs. -config_verifier - - - -bot_bot->bot_cogs_config_verifier - - - - - - -bot_cogs_moderation_modlog - -bot. -cogs. -moderation. -modlog - - - -bot_bot->bot_cogs_moderation_modlog - - - - - - -bot_cogs_sync_cog - -bot. -cogs. -sync. -cog - - - -bot_bot->bot_cogs_sync_cog - - - - - - -bot_cogs_snekbox - -bot. -cogs. -snekbox - - - -bot_bot->bot_cogs_snekbox - - - - - -bot_cogs_moderation_infractions - -bot. -cogs. -moderation. -infractions - - - -bot_bot->bot_cogs_moderation_infractions - - - - - -bot_cogs_watchchannels - -bot. -cogs. -watchchannels - - - -bot_bot->bot_cogs_watchchannels - - - - - -bot_bot->bot_cogs_doc - - - - - -bot_cogs_security - -bot. -cogs. -security - - - -bot_bot->bot_cogs_security - - - - - -bot_cogs_reminders - -bot. -cogs. -reminders - - - -bot_bot->bot_cogs_reminders - - - - - -bot_cogs_token_remover - -bot. -cogs. -token_remover - - - -bot_bot->bot_cogs_token_remover - - - - - - -bot_cogs_filtering - -bot. -cogs. -filtering - - - -bot_bot->bot_cogs_filtering - - - - - -bot_cogs_jams - -bot.cogs.jams - - - -bot_bot->bot_cogs_jams - - - - - -bot_cogs_error_handler - -bot. -cogs. -error_handler - - - -bot_bot->bot_cogs_error_handler - - - - - -bot_rules_attachments - -bot. -rules. -attachments - - - -bot_rules - -bot.rules - - - -bot_rules_attachments->bot_rules - - - - - -bot_cogs_stats - -bot.cogs.stats - - - -bot_api - -bot.api - - - -bot_api->bot_bot - - - - - -bot_api->bot_cogs_watchchannels_watchchannel - - - - -bot_cogs_moderation_utils - -bot. -cogs. -moderation. -utils - - - -bot_api->bot_cogs_moderation_utils - - - - - -bot_api->bot_cogs_watchchannels_talentpool - - - - -bot_api->bot_cogs_off_topic_names - - - - - -bot_api->bot_cogs_sync_syncers - - - - - -bot_api->bot_cogs_moderation_scheduler - - - - -bot_api->bot_cogs_sync_cog - - - - - -bot_api->bot_cogs_error_handler - - - - - - - -bot_cogs_moderation_management->bot_cogs_moderation - - - - - -urllib3_exceptions - -urllib3. -exceptions - - - -urllib3_exceptions->requests - - - - - -urllib3 - -urllib3 - - - -urllib3_exceptions->urllib3 - - - - - -urllib3_exceptions->bot_cogs_doc - - - - - -dateutil_parser - -dateutil. -parser - - - -bot_converters - -bot.converters - - - -dateutil_parser->bot_converters - - - - - -dateutil_parser->bot_cogs_watchchannels_watchchannel - - - - - - -bot_utils_time - -bot.utils.time - - - -dateutil_parser->bot_utils_time - - - - - -dateutil_parser->bot_cogs_moderation_scheduler - - - - - - -dateutil_parser->bot_cogs_reminders - - - - -dateutil_relativedelta - -dateutil. -relativedelta - - - -dateutil_relativedelta->bot_cogs_wolfram - - - - - - -dateutil_relativedelta->bot_converters - - - - - - -dateutil_relativedelta->bot_cogs_moderation_ModLog - - - - - - -dateutil_relativedelta->bot_utils_time - - - - - -dateutil_relativedelta->bot_cogs_utils - - - - - -dateutil_relativedelta->bot_cogs_moderation_modlog - - - - - -dateutil_relativedelta->bot_cogs_reminders - - - - -dateutil_relativedelta->bot_cogs_filtering - - - - - - -bot_rules_chars - -bot. -rules. -chars - - - -bot_rules_chars->bot_rules - - - - - -discord_Guild - -discord.Guild - - - -discord_Guild->bot_cogs_sync_syncers - - - - - -bot_converters->bot_cogs_moderation_management - - - - -bot_converters->bot_cogs_antispam - - - - - -bot_converters->bot_cogs_moderation_superstarify - - - - -bot_converters->bot_cogs_watchchannels_talentpool - - - - - - -bot_converters->bot_cogs_alias - - - - - -bot_converters->bot_cogs_moderation_silence - - - - -bot_converters->bot_cogs_tags - - - - - -bot_converters->bot_cogs_reddit - - - - - -bot_converters->bot_cogs_watchchannels_bigbrother - - - - -bot_converters->bot_cogs_moderation_infractions - - - - - -bot_converters->bot_cogs_doc - - - - - -bot_converters->bot_cogs_reminders - - - - -bot_converters->bot_cogs_error_handler - - - - - -bs4 - -bs4 - - - -bs4->bot_cogs_doc - - - - - -discord_Client - -discord.Client - - - -bot_utils_messages - -bot. -utils. -messages - - - -discord_Client->bot_utils_messages - - - - -bot_rules_burst_shared - -bot. -rules. -burst_shared - - - -bot_rules_burst_shared->bot_rules - - - - - -bot_cogs_watchchannels_watchchannel->bot_cogs_watchchannels_talentpool - - - - - -bot_cogs_watchchannels_watchchannel->bot_cogs_watchchannels_bigbrother - - - - - -aiohttp - -aiohttp - - - -aiohttp->bot_bot - - - - - -aiohttp->bot_api - - - - - - -aiohttp->bot_converters - - - - - -aiohttp->discord_Client - - - - - - -aiohttp->discord_Webhook - - - - - -aiohttp->bot_cogs_reddit - - - - - -bot_cogs_extensions->bot_cogs_alias - - - - - -discord_User - -discord.User - - - -discord_User->bot_cogs_clean - - - - - - -discord_User->bot_cogs_duck_pond - - - - - - - -discord_User->bot_cogs_sync_syncers - - - - - -discord_User->bot_cogs_help - - - - - -discord_User->bot_cogs_sync_cog - - - - - -discord_User->bot_cogs_snekbox - - - - - -bot_rules->bot_cogs_antispam - - - - - -bot_cogs_moderation->bot_cogs_clean - - - - - -bot_cogs_moderation->bot_cogs_webhook_remover - - - - - -bot_cogs_moderation->bot_cogs_verification - - - - - -bot_cogs_moderation->bot_cogs_watchchannels_watchchannel - - - - - -bot_cogs_moderation->bot_cogs_antispam - - - - - -bot_cogs_moderation->bot_cogs_defcon - - - - - -bot_cogs_moderation->bot_cogs_watchchannels_bigbrother - - - - -bot_cogs_moderation->bot_cogs_token_remover - - - - - -bot_cogs_moderation->bot_cogs_filtering - - - - - -discord - -discord - - - -discord->bot_cogs_clean - - - - - -discord->bot_bot - - - - - -discord->bot_rules_attachments - - - - - -discord->bot_cogs_stats - - - - - -discord->bot_cogs_moderation_management - - - - - -discord->bot_cogs_webhook_remover - - - - -discord->bot_cogs_verification - - - - - - -discord->bot_cogs_wolfram - - - - - - - -discord->bot_rules_chars - - - - - -discord->bot_converters - - - - - -discord->bot_cogs_site - - - - - - -discord->bot_rules_burst_shared - - - - - -discord->bot_cogs_watchchannels_watchchannel - - - - - -discord->bot_cogs_extensions - - - - - -discord->bot_cogs_antimalware - - - - - -discord->bot___main__ - - - - - - -discord->bot_cogs_duck_pond - - - - - -discord->bot_cogs_antispam - - - - -discord->bot_interpreter - - - - - -discord->bot_cogs_moderation_superstarify - - - - - -discord->bot_cogs_defcon - - - - - - -bot_pagination - -bot.pagination - - - -discord->bot_pagination - - - - - -discord->bot_cogs_moderation_ModLog - - - - - - -discord->bot_cogs_moderation_utils - - - - - - -bot_rules_newlines - -bot. -rules. -newlines - - - -discord->bot_rules_newlines - - - - - - -discord->bot_cogs_watchchannels_talentpool - - - - - -discord->bot_cogs_information - - - - - -discord->bot_cogs_off_topic_names - - - - - -discord->bot_cogs_alias - - - - - -discord->bot_cogs_moderation_silence - - - - - - -discord->bot_cogs_sync_syncers - - - - - - -discord->bot_cogs_moderation_scheduler - - - - - - -discord->bot_cogs_eval - - - - - - -bot_rules_burst - -bot. -rules. -burst - - - -discord->bot_rules_burst - - - - - -discord->bot_cogs_help_channels - - - - - - -discord->bot_cogs_tags - - - - - -discord->bot_cogs_reddit - - - - - - -discord->bot_cogs_utils - - - - - -bot_rules_discord_emojis - -bot. -rules. -discord_emojis - - - -discord->bot_rules_discord_emojis - - - - - -bot_rules_duplicates - -bot. -rules. -duplicates - - - -discord->bot_rules_duplicates - - - - - -discord->bot_cogs_help - - - - - -discord->bot_cogs_bot - - - - - - - -discord->bot_cogs_logging - - - - - - -bot_rules_mentions - -bot. -rules. -mentions - - - -discord->bot_rules_mentions - - - - - -bot_decorators - -bot.decorators - - - -discord->bot_decorators - - - - - -discord->bot_cogs_moderation_modlog - - - - - -discord->bot_cogs_sync_cog - - - - - -discord->bot_cogs_snekbox - - - - - -discord->bot_cogs_moderation_infractions - - - - - - -bot_rules_links - -bot. -rules. -links - - - -discord->bot_rules_links - - - - - - -discord->bot_cogs_doc - - - - - -discord->bot_utils_messages - - - - - -bot_patches_message_edited_at - -bot. -patches. -message_edited_at - - - -discord->bot_patches_message_edited_at - - - - - - - -discord->bot_cogs_reminders - - - - - -bot_rules_role_mentions - -bot. -rules. -role_mentions - - - -discord->bot_rules_role_mentions - - - - - -discord->bot_cogs_token_remover - - - - - -discord->bot_cogs_filtering - - - - - -discord->bot_cogs_jams - - - - - -bot_constants - -bot.constants - - - -bot_constants->bot_cogs_clean - - - - - -bot_constants->bot_bot - - - - - -bot_constants->bot_api - - - - - -bot_constants->bot_cogs_moderation_management - - - - - -bot_constants->bot_cogs_webhook_remover - - - - -bot_constants->bot_cogs_verification - - - - - -bot_constants->bot_cogs_wolfram - - - - -bot_constants->bot_cogs_site - - - - - - -bot_constants->bot_cogs_watchchannels_watchchannel - - - - - -bot_constants->bot_cogs_extensions - - - - - -bot_constants->bot_cogs_antimalware - - - - - -bot_constants->bot___main__ - - - - - -bot_constants->bot_cogs_duck_pond - - - - - - -bot_constants->bot_cogs_antispam - - - - - -bot_constants->bot_cogs_moderation_superstarify - - - - -bot_constants->bot_cogs_defcon - - - - - -bot_constants->bot_pagination - - - - - - -bot_constants->bot_cogs_moderation_ModLog - - - - - - -bot_constants->bot_cogs_moderation_utils - - - - - -bot_constants->bot_cogs_watchchannels_talentpool - - - - - - -bot_constants->bot_cogs_information - - - - - - -bot_constants->bot_cogs_off_topic_names - - - - - -bot_constants->bot_cogs_moderation_silence - - - - - - -bot_constants->bot_cogs_sync_syncers - - - - - -bot_constants->bot_cogs_moderation_scheduler - - - - - - -bot_constants->bot_cogs_eval - - - - - -bot_constants->bot_cogs_help_channels - - - - - -bot_constants->bot_cogs_tags - - - - - - -bot_constants->bot_cogs_reddit - - - - -bot_constants->bot_cogs_utils - - - - - -bot_constants->bot_cogs_help - - - - - - - -bot_constants->bot_cogs_bot - - - - - -bot_constants->bot_cogs_watchchannels_bigbrother - - - - - - -bot_constants->bot_cogs_logging - - - - - - -bot_constants->bot_cogs_config_verifier - - - - - -bot_constants->bot_decorators - - - - -bot_constants->bot_cogs_moderation_modlog - - - - - - -bot_constants->bot_cogs_sync_cog - - - - - - -bot_constants->bot_cogs_snekbox - - - - - - -bot_constants->bot_cogs_moderation_infractions - - - - - - -bot_constants->bot_cogs_doc - - - - - -bot_constants->bot_utils_messages - - - - - - -bot_constants->bot_cogs_reminders - - - - -bot_constants->bot_cogs_token_remover - - - - - -bot_constants->bot_cogs_filtering - - - - - - - - -bot_constants->bot_cogs_jams - - - - - -bot_constants->bot_cogs_error_handler - - - - -discord_File - -discord.File - - - -discord_File->bot_utils_messages - - - - -discord_Reaction - -discord. -Reaction - - - -discord_Reaction->bot_cogs_sync_syncers - - - - - -discord_Reaction->bot_cogs_help - - - - - -discord_Reaction->bot_cogs_snekbox - - - - - -discord_Reaction->bot_utils_messages - - - - - -bot_interpreter->bot_cogs_eval - - - - - - -bot_cogs_moderation_superstarify->bot_cogs_moderation - - - - - - -more_itertools - -more_itertools - - - -more_itertools->bot_cogs_jams - - - - - -statsd - -statsd - - - -statsd->bot_bot - - - - - -bot_pagination->bot_cogs_moderation_management - - - - - - - -bot_pagination->bot_cogs_wolfram - - - - - -bot_pagination->bot_cogs_site - - - - - -bot_pagination->bot_cogs_watchchannels_watchchannel - - - - - -bot_pagination->bot_cogs_extensions - - - - - -bot_pagination->bot_cogs_watchchannels_talentpool - - - - - -bot_pagination->bot_cogs_information - - - - - -bot_pagination->bot_cogs_off_topic_names - - - - - - -bot_pagination->bot_cogs_alias - - - - - -bot_pagination->bot_cogs_tags - - - - - -bot_pagination->bot_cogs_reddit - - - - - - -bot_pagination->bot_cogs_help - - - - - -bot_pagination->bot_cogs_doc - - - - - -bot_pagination->bot_cogs_reminders - - - - - -bot_utils_checks - -bot. -utils. -checks - - - -bot_utils_checks->bot_cogs_moderation_management - - - - - -bot_utils_checks->bot_cogs_verification - - - - - -bot_utils_checks->bot_cogs_extensions - - - - - - -bot_utils_checks->bot_cogs_moderation_superstarify - - - - - -bot_utils_checks->bot_cogs_information - - - - - -bot_utils_checks->bot_cogs_moderation_silence - - - - - - -bot_utils_checks->bot_decorators - - - - - -bot_utils_checks->bot_cogs_moderation_infractions - - - - - - -bot_utils_checks->bot_cogs_reminders - - - - -bot_cogs_moderation_ModLog->bot_cogs_clean - - - - - - -bot_cogs_moderation_ModLog->bot_cogs_verification - - - - - -bot_cogs_moderation_ModLog->bot_cogs_watchchannels_watchchannel - - - - - -bot_cogs_moderation_ModLog->bot_cogs_antispam - - - - - -bot_cogs_moderation_ModLog->bot_cogs_defcon - - - - - - -bot_cogs_moderation_ModLog->bot_cogs_token_remover - - - - - - - -bot_cogs_moderation_ModLog->bot_cogs_filtering - - - - - - - -discord_Object - -discord.Object - - - -discord_Object->bot_cogs_verification - - - - - -discord_Object->bot_cogs_antispam - - - - - - -bot_cogs_moderation_utils->bot_cogs_moderation_management - - - - - -bot_cogs_moderation_utils->bot_cogs_moderation_superstarify - - - - - - -bot_cogs_moderation_utils->bot_cogs_moderation_scheduler - - - - - -bot_cogs_moderation_utils->bot_cogs_watchchannels_bigbrother - - - - - - - -bot_cogs_moderation_utils->bot_cogs_moderation_infractions - - - - - -discord_Webhook->bot_utils_messages - - - - - - - -bot_rules_newlines->bot_rules - - - - - -bot_cogs_watchchannels_talentpool->bot_cogs_watchchannels - - - - - -discord_utils - -discord.utils - - - -discord_utils->discord_Guild - - - - - -discord_utils->discord_Client - - - - -discord_utils->discord_User - - - - - -discord_utils->discord - - - - - -discord_utils->bot_cogs_moderation_ModLog - - - - - - -discord_utils->discord_Object - - - - - -discord_utils->discord_Webhook - - - - - -discord_utils->bot_cogs_information - - - - - - -discord_Message - -discord. -Message - - - -discord_utils->discord_Message - - - - - -discord_abc - -discord.abc - - - -discord_utils->discord_abc - - - - - -discord_message - -discord. -message - - - -discord_utils->discord_message - - - - - -discord_Role - -discord.Role - - - -discord_utils->discord_Role - - - - - -discord_Member - -discord.Member - - - -discord_utils->discord_Member - - - - - - -discord_utils->bot_cogs_moderation_modlog - - - - - -discord_utils->bot_patches_message_edited_at - - - - - -discord_utils->bot_cogs_token_remover - - - - - -discord_utils->bot_cogs_filtering - - - - - - -discord_utils->bot_cogs_jams - - - - - -bot_cogs_moderation_silence->bot_cogs_moderation - - - - - -bot_utils_time->bot_cogs_moderation_management - - - - - - -bot_utils_time->bot_cogs_wolfram - - - - - -bot_utils_time->bot_cogs_watchchannels_watchchannel - - - - - -bot_utils_time->bot_cogs_moderation_superstarify - - - - - -bot_utils_time->bot_cogs_moderation_ModLog - - - - - -bot_utils_time->bot_cogs_watchchannels_talentpool - - - - -bot_utils_time->bot_cogs_information - - - - - - -bot_utils_time->bot_cogs_moderation_scheduler - - - - - -bot_utils_time->bot_cogs_utils - - - - - -bot_utils_time->bot_cogs_moderation_modlog - - - - - - - -bot_utils_time->bot_cogs_reminders - - - - - -discord_Message->bot_cogs_clean - - - - - -discord_Message->bot_rules_attachments - - - - - -discord_Message->bot_cogs_stats - - - - - -discord_Message->bot_cogs_webhook_remover - - - - - - - -discord_Message->bot_cogs_verification - - - - - -discord_Message->bot_rules_chars - - - - - - -discord_Message->bot_rules_burst_shared - - - - - -discord_Message->bot_cogs_watchchannels_watchchannel - - - - - - -discord_Message->bot_cogs_antimalware - - - - - -discord_Message->bot_cogs_duck_pond - - - - - -discord_Message->bot_cogs_antispam - - - - - -discord_Message->bot_rules_newlines - - - - - - -discord_Message->bot_cogs_information - - - - - -discord_Message->bot_cogs_sync_syncers - - - - - -discord_Message->bot_rules_burst - - - - - - - -discord_Message->bot_cogs_utils - - - - - -discord_Message->bot_rules_discord_emojis - - - - - - -discord_Message->bot_rules_duplicates - - - - - - -discord_Message->bot_cogs_help - - - - - - - - -discord_Message->bot_cogs_bot - - - - - - -discord_Message->bot_rules_mentions - - - - - - -discord_Message->bot_cogs_snekbox - - - - - - - -discord_Message->bot_rules_links - - - - - - -discord_Message->bot_utils_messages - - - - - -discord_Message->bot_rules_role_mentions - - - - - -discord_Message->bot_cogs_token_remover - - - - - - -discord_Message->bot_cogs_filtering - - - - - - - -bot_cogs_sync_syncers->bot_cogs_sync_cog - - - - - -dateutil - -dateutil - - - -dateutil->bot_cogs_wolfram - - - - - -dateutil->bot_converters - - - - - - - -dateutil->bot_cogs_watchchannels_watchchannel - - - - -dateutil->bot_cogs_moderation_ModLog - - - - - -dateutil->bot_utils_time - - - - - -dateutil->bot_cogs_moderation_scheduler - - - - - -dateutil->bot_cogs_utils - - - - - -dateutil->bot_cogs_moderation_modlog - - - - - -dateutil->bot_cogs_reminders - - - - -dateutil->bot_cogs_filtering - - - - - -bot_cogs_moderation_scheduler->bot_cogs_moderation_superstarify - - - - - -bot_cogs_moderation_scheduler->bot_cogs_moderation_infractions - - - - - -bot_rules_burst->bot_rules - - - - - -discord_abc->discord_User - - - - - -discord_abc->discord - - - - - -discord_abc->bot_pagination - - - - - -discord_abc->bot_cogs_moderation_ModLog - - - - - -discord_abc->discord_Member - - - - - -discord_abc->bot_cogs_moderation_modlog - - - - - -discord_abc->bot_utils_messages - - - - -discord_message->discord - - - - - -discord_message->discord_Webhook - - - - - -discord_message->bot_patches_message_edited_at - - - - - - -bot_rules_discord_emojis->bot_rules - - - - - -yaml - -yaml - - - -yaml->bot_constants - - - - - -bot_rules_duplicates->bot_rules - - - - - -urllib3->requests - - - - - -urllib3->bot_cogs_doc - - - - - - -discord_Role->bot_cogs_information - - - - - -discord_Role->bot_cogs_utils - - - - - -discord_Role->bot_cogs_sync_cog - - - - - -discord_Colour - -discord.Colour - - - -discord_Colour->bot_cogs_clean - - - - -discord_Colour->bot_cogs_webhook_remover - - - - - -discord_Colour->bot_cogs_verification - - - - - -discord_Colour->bot_cogs_site - - - - - - -discord_Colour->bot_cogs_extensions - - - - - -discord_Colour->bot_cogs_antispam - - - - - -discord_Colour->bot_cogs_moderation_superstarify - - - - - - -discord_Colour->bot_cogs_defcon - - - - - - -discord_Colour->bot_cogs_moderation_ModLog - - - - - - -discord_Colour->bot_cogs_information - - - - - -discord_Colour->bot_cogs_off_topic_names - - - - - -discord_Colour->bot_cogs_alias - - - - - -discord_Colour->bot_cogs_tags - - - - - -discord_Colour->bot_cogs_reddit - - - - - -discord_Colour->bot_cogs_utils - - - - - -discord_Colour->bot_cogs_help - - - - - -discord_Colour->bot_decorators - - - - - -discord_Colour->bot_cogs_moderation_modlog - - - - - - -discord_Colour->bot_cogs_token_remover - - - - - - -discord_Colour->bot_cogs_filtering - - - - - -bot_cogs_watchchannels_bigbrother->bot_cogs_watchchannels - - - - - -discord_Member->bot_rules_attachments - - - - - - - -discord_Member->bot_cogs_stats - - - - - -discord_Member->bot_rules_chars - - - - - -discord_Member->bot_rules_burst_shared - - - - - -discord_Member->bot_cogs_duck_pond - - - - - - -discord_Member->bot_cogs_antispam - - - - -discord_Member->bot_cogs_moderation_superstarify - - - - - -discord_Member->bot_cogs_defcon - - - - - -discord_Member->bot_rules_newlines - - - - - -discord_Member->bot_cogs_watchchannels_talentpool - - - - - - -discord_Member->bot_cogs_information - - - - - -discord_Member->bot_cogs_sync_syncers - - - - - -discord_Member->bot_rules_burst - - - - - -discord_Member->bot_rules_discord_emojis - - - - - -discord_Member->bot_rules_duplicates - - - - -discord_Member->bot_rules_mentions - - - - -discord_Member->bot_decorators - - - - - -discord_Member->bot_cogs_sync_cog - - - - - -discord_Member->bot_cogs_moderation_infractions - - - - - -discord_Member->bot_rules_links - - - - - -discord_Member->bot_utils_messages - - - - - - -discord_Member->bot_rules_role_mentions - - - - - -discord_Member->bot_cogs_filtering - - - - - - - -discord_Member->bot_cogs_jams - - - - - -bot_rules_mentions->bot_rules - - - - - -bot_decorators->bot_cogs_clean - - - - - - - - -bot_decorators->bot_cogs_verification - - - - -bot_decorators->bot_cogs_defcon - - - - - -bot_decorators->bot_cogs_watchchannels_talentpool - - - - - -bot_decorators->bot_cogs_information - - - - - -bot_decorators->bot_cogs_off_topic_names - - - - - -bot_decorators->bot_cogs_eval - - - - - -bot_decorators->bot_cogs_help_channels - - - - - - -bot_decorators->bot_cogs_reddit - - - - - -bot_decorators->bot_cogs_utils - - - - - -bot_decorators->bot_cogs_help - - - - - -bot_decorators->bot_cogs_bot - - - - -bot_decorators->bot_cogs_watchchannels_bigbrother - - - - - -bot_decorators->bot_cogs_snekbox - - - - - - -bot_decorators->bot_cogs_moderation_infractions - - - - - -bot_decorators->bot_cogs_doc - - - - - -bot_decorators->bot_cogs_jams - - - - - -bot_decorators->bot_cogs_error_handler - - - - - -bot_cogs_moderation_modlog->bot_cogs_moderation_management - - - - - - -bot_cogs_moderation_modlog->bot_cogs_webhook_remover - - - - - -bot_cogs_moderation_modlog->bot_cogs_moderation - - - - -bot_cogs_moderation_modlog->bot_cogs_moderation_scheduler - - - - - -bot_patches - -bot.patches - - - -bot_patches->bot___main__ - - - - - -bot_cogs_sync_cog->bot_cogs_sync - - - - - -bot_utils - -bot.utils - - - -bot_utils->bot_cogs_moderation_management - - - - -bot_utils->bot_cogs_verification - - - - - - -bot_utils->bot_cogs_wolfram - - - - - -bot_utils->bot_cogs_watchchannels_watchchannel - - - - - - -bot_utils->bot_cogs_extensions - - - - - -bot_utils->bot_cogs_duck_pond - - - - -bot_utils->bot_cogs_antispam - - - - - - -bot_utils->bot_cogs_moderation_superstarify - - - - - -bot_utils->bot_cogs_moderation_ModLog - - - - - - -bot_utils->bot_cogs_watchchannels_talentpool - - - - - - -bot_utils->bot_cogs_information - - - - -bot_utils->bot_cogs_moderation_silence - - - - - - - -bot_utils->bot_cogs_moderation_scheduler - - - - - -bot_utils->bot_cogs_help_channels - - - - - -bot_utils->bot_cogs_tags - - - - - -bot_utils->bot_cogs_utils - - - - - -bot_utils->bot_cogs_bot - - - - - - -bot_utils->bot_decorators - - - - - -bot_utils->bot_cogs_moderation_modlog - - - - - -bot_utils->bot_cogs_snekbox - - - - - -bot_utils->bot_cogs_moderation_infractions - - - - - - - -bot_utils->bot_cogs_reminders - - - - - - -bot_cogs_moderation_infractions->bot_cogs_moderation_management - - - - - -bot_cogs_moderation_infractions->bot_cogs_moderation - - - - - -bot_rules_links->bot_rules - - - - - -discord_errors - -discord.errors - - - -discord_errors->discord_Guild - - - - - -discord_errors->discord_Client - - - - - - -discord_errors->bot_cogs_watchchannels_watchchannel - - - - -discord_errors->discord_User - - - - - - -discord_errors->discord - - - - - -discord_errors->bot_cogs_duck_pond - - - - - -discord_errors->discord_Webhook - - - - - -discord_errors->discord_utils - - - - - -discord_errors->discord_Message - - - - - - -discord_errors->discord_abc - - - - - -discord_errors->discord_message - - - - - -discord_errors->discord_Role - - - - - - -discord_errors->bot_decorators - - - - - - -discord_errors->bot_cogs_doc - - - - - - -discord_errors->bot_utils_messages - - - - - -discord_errors->bot_cogs_filtering - - - - - -bot_utils_scheduling - -bot. -utils. -scheduling - - - -bot_utils_scheduling->bot_cogs_moderation_scheduler - - - - -bot_utils_scheduling->bot_cogs_help_channels - - - - - -bot_utils_scheduling->bot_cogs_reminders - - - - - -bs4_element - -bs4.element - - - -bs4_element->bs4 - - - - - -bs4_element->bot_cogs_doc - - - - - -dateutil_tz - -dateutil.tz - - - -dateutil_tz->bot_converters - - - - -bot_utils_messages->bot_cogs_watchchannels_watchchannel - - - - - - - -bot_utils_messages->bot_cogs_duck_pond - - - - - -bot_utils_messages->bot_cogs_antispam - - - - - - -bot_utils_messages->bot_cogs_tags - - - - - -bot_utils_messages->bot_cogs_bot - - - - -bot_utils_messages->bot_cogs_snekbox - - - - -bot_patches_message_edited_at->bot_patches - - - - - -bot_rules_role_mentions->bot_rules - - - - - -bot_cogs_token_remover->bot_cogs_bot - - - - - diff --git a/bot/cogs/stats.py b/bot/cogs/stats.py index e963dc312..8fb7d8639 100644 --- a/bot/cogs/stats.py +++ b/bot/cogs/stats.py @@ -1,14 +1,14 @@ from discord import Member, Message, Status from discord.ext.commands import Bot, Cog, Context -from bot.constants import Guild +from bot.constants import Channels, Guild CHANNEL_NAME_OVERRIDES = { - Guild.channels.off_topic_0: "off_topic_0", - Guild.channels.off_topic_1: "off_topic_1", - Guild.channels.off_topic_2: "off_topic_2", - Guild.channels.staff_lounge: "staff_lounge" + Channels.off_topic_0: "off_topic_0", + Channels.off_topic_1: "off_topic_1", + Channels.off_topic_2: "off_topic_2", + Channels.staff_lounge: "staff_lounge" } -- cgit v1.2.3 From bca47a1c2e59fb112b947876cea1836879ac7282 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Sat, 11 Apr 2020 21:02:13 +0100 Subject: Implement an AsyncStatsClient to send statsd communications asynchronously --- bot/async_stats.py | 39 +++++++++++++++++++++++++++++++++++++++ bot/bot.py | 13 ++++++++++--- 2 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 bot/async_stats.py diff --git a/bot/async_stats.py b/bot/async_stats.py new file mode 100644 index 000000000..58a80f528 --- /dev/null +++ b/bot/async_stats.py @@ -0,0 +1,39 @@ +import asyncio +import socket + +from statsd.client.base import StatsClientBase + + +class AsyncStatsClient(StatsClientBase): + """An async transport method for statsd communication.""" + + def __init__( + self, + loop: asyncio.AbstractEventLoop, + host: str = 'localhost', + port: int = 8125, + prefix: str = None + ): + """Create a new client.""" + family, _, _, _, addr = socket.getaddrinfo( + host, port, socket.AF_INET, socket.SOCK_DGRAM)[0] + self._addr = addr + self._prefix = prefix + self._loop = loop + self._transport = None + + async def create_socket(self) -> None: + """Use the loop.create_datagram_endpoint method to create a socket.""" + self._transport, _ = await self._loop.create_datagram_endpoint( + asyncio.DatagramProtocol, + family=socket.AF_INET, + remote_addr=self._addr + ) + + def _send(self, data: str) -> None: + """Start an async task to send data to statsd.""" + self._loop.create_task(self._async_send(data)) + + async def _async_send(self, data: str) -> None: + """Send data to the statsd server using the async transport.""" + self._transport.sendto(data.encode('ascii'), self._addr) diff --git a/bot/bot.py b/bot/bot.py index 65081e438..c5d490409 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -6,10 +6,10 @@ from typing import Optional import aiohttp import discord -import statsd from discord.ext import commands from bot import DEBUG_MODE, api, constants +from bot.async_stats import AsyncStatsClient log = logging.getLogger('bot') @@ -41,7 +41,7 @@ class Bot(commands.Bot): # will effectively disable stats. statsd_url = "127.0.0.1" - self.stats = statsd.StatsClient(statsd_url, 8125, prefix="bot") + self.stats = AsyncStatsClient(self.loop, statsd_url, 8125, prefix="bot") def add_cog(self, cog: commands.Cog) -> None: """Adds a "cog" to the bot and logs the operation.""" @@ -60,7 +60,7 @@ class Bot(commands.Bot): super().clear() async def close(self) -> None: - """Close the Discord connection and the aiohttp session, connector, and resolver.""" + """Close the Discord connection and the aiohttp session, connector, statsd client, and resolver.""" await super().close() await self.api_client.close() @@ -74,6 +74,9 @@ class Bot(commands.Bot): if self._resolver: await self._resolver.close() + if self.stats._transport: + await self.stats._transport.close() + async def login(self, *args, **kwargs) -> None: """Re-create the connector and set up sessions before logging into Discord.""" self._recreate() @@ -111,6 +114,10 @@ class Bot(commands.Bot): self.http_session = aiohttp.ClientSession(connector=self._connector) self.api_client.recreate(force=True, connector=self._connector) + async def on_ready(self) -> None: + """Construct an asynchronous transport for the statsd client.""" + await self.stats.create_socket() + async def on_guild_available(self, guild: discord.Guild) -> None: """ Set the internal guild available event when constants.Guild.id becomes available. -- cgit v1.2.3 From 7da559647db7dfd4386f1711e2c053efd9a6c897 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Sat, 11 Apr 2020 21:37:49 +0100 Subject: Move create_socket to the login method of the bot --- bot/bot.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index c5d490409..ef4a325dc 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -80,6 +80,7 @@ class Bot(commands.Bot): async def login(self, *args, **kwargs) -> None: """Re-create the connector and set up sessions before logging into Discord.""" self._recreate() + await self.stats.create_socket() await super().login(*args, **kwargs) def _recreate(self) -> None: @@ -114,10 +115,6 @@ class Bot(commands.Bot): self.http_session = aiohttp.ClientSession(connector=self._connector) self.api_client.recreate(force=True, connector=self._connector) - async def on_ready(self) -> None: - """Construct an asynchronous transport for the statsd client.""" - await self.stats.create_socket() - async def on_guild_available(self, guild: discord.Guild) -> None: """ Set the internal guild available event when constants.Guild.id becomes available. -- cgit v1.2.3 From ee5a4df9537b46cdceb35243d887e84601d07795 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Sat, 11 Apr 2020 22:11:43 +0100 Subject: Additional statistics --- bot/cogs/defcon.py | 2 ++ bot/cogs/error_handler.py | 14 +++++++++++++- bot/cogs/help_channels.py | 24 ++++++++++++++++++++---- bot/cogs/stats.py | 14 ++++++++++---- 4 files changed, 45 insertions(+), 9 deletions(-) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 06b2f25c6..9197dcca3 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -146,6 +146,8 @@ class Defcon(Cog): await ctx.send(self.build_defcon_msg(action, error)) await self.send_defcon_log(action, ctx.author, error) + self.bot.stats.gauge("defcon.days", days) + @defcon_group.command(name='enable', aliases=('on', 'e')) @with_role(Roles.admins, Roles.owners) async def enable_command(self, ctx: Context) -> None: diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 722376cc6..dae283c6a 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -171,19 +171,25 @@ class ErrorHandler(Cog): if isinstance(e, errors.MissingRequiredArgument): await ctx.send(f"Missing required argument `{e.param.name}`.") await ctx.invoke(*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) + 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) + self.bot.stats.incr("errors.bad_argument") elif isinstance(e, errors.BadUnionArgument): await ctx.send(f"Bad argument: {e}\n```{e.errors[-1]}```") + self.bot.stats.incr("errors.bad_union_argument") elif isinstance(e, errors.ArgumentParsingError): await ctx.send(f"Argument parsing error: {e}") + 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) + self.bot.stats.incr("errors.other_user_input_error") @staticmethod async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None: @@ -205,10 +211,12 @@ class ErrorHandler(Cog): ) if isinstance(e, bot_missing_errors): + ctx.bot.stats.incr("errors.bot_permission_error") await ctx.send( f"Sorry, it looks like I don't have the permissions or roles I need to do that." ) elif isinstance(e, (InChannelCheckFailure, errors.NoPrivateMessage)): + ctx.bot.stats.incr("errors.wrong_channel_or_dm_error") await ctx.send(e) @staticmethod @@ -217,16 +225,20 @@ class ErrorHandler(Cog): if e.status == 404: await ctx.send("There does not seem to be anything matching your query.") log.debug(f"API responded with 404 for command {ctx.command}") + ctx.bot.stats.incr("errors.api_error_404") elif e.status == 400: content = await e.response.json() log.debug(f"API responded with 400 for command {ctx.command}: %r.", content) await ctx.send("According to the API, your request is malformed.") + ctx.bot.stats.incr("errors.api_error_400") elif 500 <= e.status < 600: await ctx.send("Sorry, there seems to be an internal issue with the API.") log.warning(f"API responded with {e.status} for command {ctx.command}") + ctx.bot.stats.incr("errors.api_internal_server_error") else: await ctx.send(f"Got an unexpected status code from the API (`{e.status}`).") log.warning(f"Unexpected API response for command {ctx.command}: {e.status}") + ctx.bot.stats.incr(f"errors.api_error_{e.status}") @staticmethod async def handle_unexpected_error(ctx: Context, e: errors.CommandError) -> None: @@ -236,7 +248,7 @@ class ErrorHandler(Cog): f"```{e.__class__.__name__}: {e}```" ) - ctx.bot.stats.incr("errors.commands") + ctx.bot.stats.incr("errors.unexpected") with push_scope() as scope: scope.user = { diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 389a4ad2a..01a77db2b 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -127,6 +127,9 @@ class HelpChannels(Scheduler, commands.Cog): self.on_message_lock = asyncio.Lock() self.init_task = self.bot.loop.create_task(self.init_cog()) + # Stats + self.claim_times = {} + def cog_unload(self) -> None: """Cancel the init task and scheduled tasks when the cog unloads.""" log.trace("Cog unload: cancelling the init_cog task") @@ -195,7 +198,7 @@ class HelpChannels(Scheduler, commands.Cog): if ctx.channel.category == self.in_use_category: self.cancel_task(ctx.channel.id) - await self.move_to_dormant(ctx.channel) + await self.move_to_dormant(ctx.channel, "command") else: log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") @@ -406,7 +409,7 @@ class HelpChannels(Scheduler, commands.Cog): f"and will be made dormant." ) - await self.move_to_dormant(channel) + await self.move_to_dormant(channel, "auto") else: # Cancel the existing task, if any. if has_task: @@ -446,8 +449,12 @@ class HelpChannels(Scheduler, commands.Cog): await self.ensure_permissions_synchronization(self.available_category) self.report_stats() - async def move_to_dormant(self, channel: discord.TextChannel) -> None: - """Make the `channel` dormant.""" + async def move_to_dormant(self, channel: discord.TextChannel, caller: str) -> None: + """ + Make the `channel` dormant. + + A caller argument is provided for metrics. + """ log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") await channel.edit( @@ -457,6 +464,13 @@ class HelpChannels(Scheduler, commands.Cog): topic=DORMANT_TOPIC, ) + self.bot.stats.incr(f"help.dormant_calls.{caller}") + + if self.claim_times.get(channel.id): + claimed = self.claim_times[channel.id] + in_use_time = datetime.now() - claimed + self.bot.stats.timer("help.in_use_time", in_use_time) + log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.") log.trace(f"Sending dormant message for #{channel} ({channel.id}).") @@ -560,6 +574,8 @@ class HelpChannels(Scheduler, commands.Cog): self.bot.stats.incr("help.claimed") + self.claim_times[channel.id] = datetime.now() + log.trace(f"Releasing on_message lock for {message.id}.") # Move a dormant channel to the Available category to fill in the gap. diff --git a/bot/cogs/stats.py b/bot/cogs/stats.py index 8fb7d8639..772ae2c97 100644 --- a/bot/cogs/stats.py +++ b/bot/cogs/stats.py @@ -1,3 +1,5 @@ +import string + from discord import Member, Message, Status from discord.ext.commands import Bot, Cog, Context @@ -11,6 +13,8 @@ CHANNEL_NAME_OVERRIDES = { Channels.staff_lounge: "staff_lounge" } +ALLOWED_CHARS = string.ascii_letters + string.digits + class Stats(Cog): """A cog which provides a way to hook onto Discord events and forward to stats.""" @@ -32,6 +36,8 @@ class Stats(Cog): if CHANNEL_NAME_OVERRIDES.get(message.channel.id): reformatted_name = CHANNEL_NAME_OVERRIDES.get(message.channel.id) + reformatted_name = "".join([char for char in reformatted_name if char in ALLOWED_CHARS]) + stat_name = f"channels.{reformatted_name}" self.bot.stats.incr(stat_name) @@ -73,13 +79,13 @@ class Stats(Cog): offline = 0 for member in after.guild.members: - if member.status == Status.online: + if member.status is Status.online: online += 1 - elif member.status == Status.dnd: + elif member.status is Status.dnd: dnd += 1 - elif member.status == Status.idle: + elif member.status is Status.idle: idle += 1 - else: + elif member.status is Status.offline: offline += 1 self.bot.stats.gauge("guild.status.online", online) -- cgit v1.2.3 From ea9db39715199ea05eafe124dbf74231d1e7e3d4 Mon Sep 17 00:00:00 2001 From: Joseph Date: Sat, 11 Apr 2020 22:25:44 +0100 Subject: Update bot/cogs/stats.py Co-Authored-By: Mark --- bot/cogs/stats.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/stats.py b/bot/cogs/stats.py index 772ae2c97..c15d0eb1b 100644 --- a/bot/cogs/stats.py +++ b/bot/cogs/stats.py @@ -36,7 +36,7 @@ class Stats(Cog): if CHANNEL_NAME_OVERRIDES.get(message.channel.id): reformatted_name = CHANNEL_NAME_OVERRIDES.get(message.channel.id) - reformatted_name = "".join([char for char in reformatted_name if char in ALLOWED_CHARS]) + reformatted_name = "".join(char for char in reformatted_name if char in ALLOWED_CHARS) stat_name = f"channels.{reformatted_name}" self.bot.stats.incr(stat_name) -- cgit v1.2.3 From 33c40ce2913e2a324647c4eb8f5c511cb26cf8ae Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Sat, 11 Apr 2020 22:28:24 +0100 Subject: Use in for membership check as opposed to .get() --- 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 01a77db2b..632c78701 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -466,7 +466,7 @@ class HelpChannels(Scheduler, commands.Cog): self.bot.stats.incr(f"help.dormant_calls.{caller}") - if self.claim_times.get(channel.id): + if channel.id in self.claim_times: claimed = self.claim_times[channel.id] in_use_time = datetime.now() - claimed self.bot.stats.timer("help.in_use_time", in_use_time) -- cgit v1.2.3 From 673869062ff49a74a2d2f8338c4d26003bee995e Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Sat, 11 Apr 2020 22:44:13 +0100 Subject: Add a metric for tracking how long defcon was active --- bot/cogs/defcon.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 9197dcca3..7043c7cbb 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -126,6 +126,19 @@ class Defcon(Cog): async def _defcon_action(self, ctx: Context, days: int, action: Action) -> None: """Providing a structured way to do an defcon action.""" + try: + response = await self.bot.api_client.get('bot/bot-settings/defcon') + data = response['data'] + + if "enable_date" in data and action is Action.DISABLED: + enabled = datetime.fromisoformat(data["enable_date"]) + + delta = datetime.now() - enabled + + self.bot.stats.timer("defcon.enabled", delta) + except Exception: + pass + error = None try: await self.bot.api_client.put( @@ -136,6 +149,7 @@ class Defcon(Cog): # TODO: retrieve old days count 'days': days, 'enabled': action is not Action.DISABLED, + 'enable_date': datetime.now().isoformat() } } ) @@ -146,7 +160,7 @@ class Defcon(Cog): await ctx.send(self.build_defcon_msg(action, error)) await self.send_defcon_log(action, ctx.author, error) - self.bot.stats.gauge("defcon.days", days) + self.bot.stats.gauge("defcon.threshold", days) @defcon_group.command(name='enable', aliases=('on', 'e')) @with_role(Roles.admins, Roles.owners) -- cgit v1.2.3 From 4adcb0b0ab2b8768e31043429bdfcbf4dab607e6 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Sun, 12 Apr 2020 00:09:53 +0100 Subject: Address aeros' review comment regarding help channel stat reporting --- bot/cogs/help_channels.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 632c78701..d260a6a33 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -374,9 +374,9 @@ class HelpChannels(Scheduler, commands.Cog): def report_stats(self) -> None: """Report the channel count stats.""" - total_in_use = len(list(self.get_category_channels(self.in_use_category))) - total_available = len(list(self.get_category_channels(self.available_category))) - total_dormant = len(list(self.get_category_channels(self.dormant_category))) + total_in_use = sum(1 for _ in self.get_category_channels(self.in_use_category)) + total_available = sum(1 for _ in self.get_category_channels(self.available_category)) + total_dormant = sum(1 for _ in self.get_category_channels(self.dormant_category)) self.bot.stats.gauge("help.total.in_use", total_in_use) self.bot.stats.gauge("help.total.available", total_available) -- cgit v1.2.3 From d5cd996864fe213d1c804911c2a17a6d04b8e170 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Sun, 12 Apr 2020 01:00:27 +0100 Subject: Add a timeout to prevent the bot from being overloaded with presence updates --- bot/bot.py | 2 +- bot/cogs/stats.py | 12 ++++++++++-- bot/constants.py | 8 +++++++- config-default.yml | 4 +++- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index ef4a325dc..6dd5ba896 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -33,7 +33,7 @@ class Bot(commands.Bot): self._resolver = None self._guild_available = asyncio.Event() - statsd_url = constants.Bot.statsd_host + statsd_url = constants.Stats.statsd_host if DEBUG_MODE: # Since statsd is UDP, there are no errors for sending to a down port. diff --git a/bot/cogs/stats.py b/bot/cogs/stats.py index c15d0eb1b..df4827ba1 100644 --- a/bot/cogs/stats.py +++ b/bot/cogs/stats.py @@ -1,9 +1,10 @@ import string +from datetime import datetime from discord import Member, Message, Status from discord.ext.commands import Bot, Cog, Context -from bot.constants import Channels, Guild +from bot.constants import Channels, Guild, Stats as StatConf CHANNEL_NAME_OVERRIDES = { @@ -13,7 +14,7 @@ CHANNEL_NAME_OVERRIDES = { Channels.staff_lounge: "staff_lounge" } -ALLOWED_CHARS = string.ascii_letters + string.digits +ALLOWED_CHARS = string.ascii_letters + string.digits + "-" class Stats(Cog): @@ -21,6 +22,7 @@ class Stats(Cog): def __init__(self, bot: Bot): self.bot = bot + self.last_presence_update = None @Cog.listener() async def on_message(self, message: Message) -> None: @@ -73,6 +75,12 @@ class Stats(Cog): if after.guild.id != Guild.id: return + if self.last_presence_update: + if (datetime.now() - self.last_presence_update).seconds < StatConf.presence_update_timeout: + return + + self.last_presence_update = datetime.now() + online = 0 idle = 0 dnd = 0 diff --git a/bot/constants.py b/bot/constants.py index 33c1d530d..2add028e7 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -199,7 +199,6 @@ class Bot(metaclass=YAMLGetter): prefix: str token: str sentry_dsn: str - statsd_host: str class Filter(metaclass=YAMLGetter): section = "filter" @@ -351,6 +350,13 @@ class CleanMessages(metaclass=YAMLGetter): message_limit: int +class Stats(metaclass=YAMLGetter): + section = "bot" + subsection = "stats" + + presence_update_timeout: int + statsd_host: str + class Categories(metaclass=YAMLGetter): section = "guild" diff --git a/config-default.yml b/config-default.yml index 567caacbf..4cd61ce10 100644 --- a/config-default.yml +++ b/config-default.yml @@ -3,7 +3,9 @@ bot: token: !ENV "BOT_TOKEN" sentry_dsn: !ENV "BOT_SENTRY_DSN" - statsd_host: "graphite" + stats: + statsd_host: "graphite" + presence_update_timeout: 300 cooldowns: # Per channel, per tag. -- cgit v1.2.3 From a24f3736b37e8de9978931dc415d702d98f46b5c Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Sun, 12 Apr 2020 01:18:48 +0100 Subject: Use underscore for metric names instead of dash --- bot/cogs/stats.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/stats.py b/bot/cogs/stats.py index df4827ba1..d253db913 100644 --- a/bot/cogs/stats.py +++ b/bot/cogs/stats.py @@ -14,7 +14,7 @@ CHANNEL_NAME_OVERRIDES = { Channels.staff_lounge: "staff_lounge" } -ALLOWED_CHARS = string.ascii_letters + string.digits + "-" +ALLOWED_CHARS = string.ascii_letters + string.digits + "_" class Stats(Cog): -- cgit v1.2.3 From 540700694c7918058bf66af23a6351438423c155 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Sun, 12 Apr 2020 01:48:58 +0100 Subject: timer -> timing for statsd --- bot/cogs/defcon.py | 2 +- bot/cogs/help_channels.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 7043c7cbb..56fca002a 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -135,7 +135,7 @@ class Defcon(Cog): delta = datetime.now() - enabled - self.bot.stats.timer("defcon.enabled", delta) + self.bot.stats.timing("defcon.enabled", delta) except Exception: pass diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index d260a6a33..12bc4e279 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -469,7 +469,7 @@ class HelpChannels(Scheduler, commands.Cog): if channel.id in self.claim_times: claimed = self.claim_times[channel.id] in_use_time = datetime.now() - claimed - self.bot.stats.timer("help.in_use_time", in_use_time) + self.bot.stats.timing("help.in_use_time", in_use_time) log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.") -- cgit v1.2.3 From 7f1e65f51d6c597214bbdd7002d43afa4617e0c2 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Sun, 12 Apr 2020 11:12:01 +0100 Subject: [stat] Create a statistic for whether dormant was called by the claimant or staff --- bot/cogs/help_channels.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 4dd70d7bf..36ad6a7a3 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -198,10 +198,16 @@ class HelpChannels(Scheduler, commands.Cog): """Return True if the user is the help channel claimant or passes the role check.""" if self.help_channel_claimants.get(ctx.channel) == ctx.author: log.trace(f"{ctx.author} is the help channel claimant, passing the check for dormant.") + self.bot.stats.incr("help.dormant_invoke.claimant") return True log.trace(f"{ctx.author} is not the help channel claimant, checking roles.") - return with_role_check(ctx, *constants.HelpChannels.cmd_whitelist) + role_check = with_role_check(ctx, *constants.HelpChannels.cmd_whitelist) + + if role_check: + self.bot.stats.incr("help.dormant_invoke.staff") + + return role_check @commands.command(name="dormant", aliases=["close"], enabled=False) async def dormant_command(self, ctx: commands.Context) -> None: -- cgit v1.2.3 From 3d25c790a69421e0ed9c7c7a29ca1d5833322169 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Mon, 13 Apr 2020 03:20:43 +0100 Subject: [stat] Tag statistic was using the user input as the series name, not the resolved tag name --- bot/cogs/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index b81859db1..9ba33d7e0 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -208,7 +208,7 @@ class Tags(Cog): "channel": ctx.channel.id } - self.bot.stats.incr(f"tags.usages.{tag_name.replace('-', '_')}") + self.bot.stats.incr(f"tags.usages.{tag['title'].replace('-', '_')}") await wait_for_deletion( await ctx.send(embed=Embed.from_dict(tag['embed'])), -- cgit v1.2.3 From ea81f3b23192cf0840144cecfb2ca721c89a34fe Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Tue, 14 Apr 2020 11:04:55 +0200 Subject: Revert deletion of !dormant invocation messages PR #868 introduced the automatic deletion of the message that issued the `!dormant` command. The idea behind this was that moving the channel to the dormant category makes it obvious that a channel has gone dormant and the message would only serve as visual clutter. However, removing the command invocation also means that it's less obvious why a channel was moved to the dormant category. As the message gets deleted almost immediately, you have to be actively watching the channel to know that the command was issued and who issued it. This has already caused some confusion where helping members where left wondering why a channel suddenly went dormant while they felt that the conversation was still ongoing. To improve the user experience, this commit removes the deletions of the command invocation messages. --- bot/cogs/help_channels.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 36ad6a7a3..692bb234c 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -224,10 +224,6 @@ class HelpChannels(Scheduler, commands.Cog): with suppress(KeyError): del self.help_channel_claimants[ctx.channel] - with suppress(discord.errors.NotFound): - log.trace("Deleting dormant invokation message.") - await ctx.message.delete() - with suppress(discord.errors.HTTPException, discord.errors.NotFound): await self.reset_claimant_send_permission(ctx.channel) -- cgit v1.2.3 From 36c3535c109e19e8a337aa4918bcc081a3843813 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 16 Apr 2020 16:02:05 +0200 Subject: Create temporary free tag --- bot/resources/tags/free.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 bot/resources/tags/free.md diff --git a/bot/resources/tags/free.md b/bot/resources/tags/free.md new file mode 100644 index 000000000..efa20a123 --- /dev/null +++ b/bot/resources/tags/free.md @@ -0,0 +1,5 @@ +We recently moved to a new help channel system. There are always 2 available help channels waiting to be claimed in the **Python Help: Available category**. In order to claim one, simply start typing your question into one of these channels. Once your question has been posted, you have claimed this channel, and the channel will be moved down to the **Python Help: Occupied category**. + +If you're unable to type into these channels, this means you're currently on cooldown. In order to prevent someone from claiming all the channels for themselves, we only allow someone to claim a new help channel every 15 minutes. However, if you close your help channel using the `!dormant` command, this cooldown is reset early. + +For more information, check out [our website](https://pythondiscord.com/pages/resources/guides/help-channels/). -- cgit v1.2.3 From b44caed7c5b26f13b4b31d237ffe07f3157aadfb Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Thu, 16 Apr 2020 16:21:25 +0200 Subject: Remove `.md` from anti-malware whitelist We want our members to use the paste site to share text-based files instead of them sharing the files as attachments on Discord. As `.md`, a file extensions used for plain-text files with markdown formatting, is such a text file, I've removed it from the anti-malware whitelist. --- config-default.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 4cd61ce10..f2b0bfa9f 100644 --- a/config-default.yml +++ b/config-default.yml @@ -475,7 +475,6 @@ anti_malware: - '.mp3' - '.wav' - '.ogg' - - '.md' reddit: -- cgit v1.2.3 From 271da4a5c93440d39204ea875e2f67b10eb7c45d Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 16 Apr 2020 16:46:37 +0200 Subject: Add a title at the top of the free tag Co-Authored-By: Shirayuki Nekomata --- bot/resources/tags/free.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/resources/tags/free.md b/bot/resources/tags/free.md index efa20a123..3cb8452b0 100644 --- a/bot/resources/tags/free.md +++ b/bot/resources/tags/free.md @@ -1,3 +1,5 @@ +**How to claim a channel** + We recently moved to a new help channel system. There are always 2 available help channels waiting to be claimed in the **Python Help: Available category**. In order to claim one, simply start typing your question into one of these channels. Once your question has been posted, you have claimed this channel, and the channel will be moved down to the **Python Help: Occupied category**. If you're unable to type into these channels, this means you're currently on cooldown. In order to prevent someone from claiming all the channels for themselves, we only allow someone to claim a new help channel every 15 minutes. However, if you close your help channel using the `!dormant` command, this cooldown is reset early. -- cgit v1.2.3 From af2c21618575bd260c60d20b79eb5e7e6a9efe37 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 16 Apr 2020 16:47:28 +0200 Subject: Use IDs instead of hard-coding category names in the free tag Co-Authored-By: Shirayuki Nekomata --- bot/resources/tags/free.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/resources/tags/free.md b/bot/resources/tags/free.md index 3cb8452b0..6d0f3618a 100644 --- a/bot/resources/tags/free.md +++ b/bot/resources/tags/free.md @@ -1,7 +1,6 @@ **How to claim a channel** -We recently moved to a new help channel system. There are always 2 available help channels waiting to be claimed in the **Python Help: Available category**. In order to claim one, simply start typing your question into one of these channels. Once your question has been posted, you have claimed this channel, and the channel will be moved down to the **Python Help: Occupied category**. - +We recently moved to a new help channel system. There are always 2 available help channels waiting to be claimed in the **<#691405807388196926>**. In order to claim one, simply start typing your question into one of these channels. Once your question has been posted, you have claimed this channel, and the channel will be moved down to the **<#696958401460043776>**. If you're unable to type into these channels, this means you're currently on cooldown. In order to prevent someone from claiming all the channels for themselves, we only allow someone to claim a new help channel every 15 minutes. However, if you close your help channel using the `!dormant` command, this cooldown is reset early. For more information, check out [our website](https://pythondiscord.com/pages/resources/guides/help-channels/). -- cgit v1.2.3 From 27ed536350640fd1ce3fee5c21cfa6b495b4793e Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 17 Apr 2020 10:09:23 -0700 Subject: HelpChannels: ensure `is_in_category` returns a bool Co-Authored-By: kwzrd <44734341+kwzrd@users.noreply.github.com> --- 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 56caa60af..170812d1b 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -380,7 +380,7 @@ class HelpChannels(Scheduler, commands.Cog): def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: """Return True if `channel` is within a category with `category_id`.""" actual_category = getattr(channel, "category", None) - return actual_category and actual_category.id == category_id + return actual_category is not None and actual_category.id == category_id async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None: """ -- cgit v1.2.3 From 5416280755631f7051e99e8a074af50c98974944 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 12 Apr 2020 11:56:54 -0700 Subject: Constants: add help channel cooldown role --- bot/constants.py | 1 + config-default.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 2add028e7..49098c9f2 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -421,6 +421,7 @@ class Roles(metaclass=YAMLGetter): announcements: int contributors: int core_developers: int + help_cooldown: int helpers: int jammers: int moderators: int diff --git a/config-default.yml b/config-default.yml index f2b0bfa9f..b0165adf6 100644 --- a/config-default.yml +++ b/config-default.yml @@ -201,6 +201,7 @@ guild: roles: announcements: 463658397560995840 contributors: 295488872404484098 + help_cooldown: 699189276025421825 muted: &MUTED_ROLE 277914926603829249 partners: 323426753857191936 python_community: &PY_COMMUNITY_ROLE 458226413825294336 -- cgit v1.2.3 From 9e67ebedcdc181ab0f90307afca5cbc0b1b9e816 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 12 Apr 2020 12:03:11 -0700 Subject: HelpChannels: remove ensure_permissions_synchronization --- bot/cogs/help_channels.py | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index e73bbdae5..56d2d26cd 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -481,7 +481,6 @@ class HelpChannels(Scheduler, commands.Cog): f"Ensuring that all channels in `{self.available_category}` have " f"synchronized permissions after moving `{channel}` into it." ) - await self.ensure_permissions_synchronization(self.available_category) self.report_stats() async def move_to_dormant(self, channel: discord.TextChannel, caller: str) -> None: @@ -620,39 +619,13 @@ class HelpChannels(Scheduler, commands.Cog): # be put in the queue. await self.move_to_available() - @staticmethod - async def ensure_permissions_synchronization(category: discord.CategoryChannel) -> None: - """ - Ensure that all channels in the `category` have their permissions synchronized. - - This method mitigates an issue we have yet to find the cause for: Every so often, a channel in the - `Help: Available` category gets in a state in which it will no longer synchronizes its permissions - with the category. To prevent that, we iterate over the channels in the category and edit the channels - that are observed to be in such a state. If no "out of sync" channels are observed, this method will - not make API calls and should be fairly inexpensive to run. - """ - for channel in category.channels: - if not channel.permissions_synced: - log.info(f"The permissions of channel `{channel}` were out of sync with category `{category}`.") - await channel.edit(sync_permissions=True) - async def update_category_permissions( self, category: discord.CategoryChannel, member: discord.Member, **permissions ) -> None: - """ - Update the permissions of the given `member` for the given `category` with `permissions` passed. - - After updating the permissions for the member in the category, this helper function will call the - `ensure_permissions_synchronization` method to ensure that all channels are still synchronizing their - permissions with the category. It's currently unknown why some channels get "out of sync", but this - hopefully mitigates the issue. - """ + """Update the permissions of the given `member` for the given `category` with `permissions` passed.""" log.trace(f"Updating permissions for `{member}` in `{category}` with {permissions}.") await category.set_permissions(member, **permissions) - log.trace(f"Ensuring that all channels in `{category}` are synchronized after permissions update.") - await self.ensure_permissions_synchronization(category) - async def reset_send_permissions(self) -> None: """Reset send permissions for members with it set to False in the Available category.""" log.trace("Resetting send permissions in the Available category.") @@ -666,7 +639,6 @@ class HelpChannels(Scheduler, commands.Cog): await self.available_category.set_permissions(member, overwrite=None) log.trace(f"Ensuring channels in `Help: Available` are synchronized after permissions reset.") - await self.ensure_permissions_synchronization(self.available_category) async def reset_claimant_send_permission(self, channel: discord.TextChannel) -> None: """Reset send permissions in the Available category for the help `channel` claimant.""" -- cgit v1.2.3 From 06d12a02b91535b8536f877fdcd0d85aac6b1039 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 11:08:26 -0700 Subject: HelpChannels: add helper function to check for claimant role --- bot/cogs/help_channels.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 56d2d26cd..d47a42ca6 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -412,6 +412,11 @@ class HelpChannels(Scheduler, commands.Cog): self.bot.stats.gauge("help.total.available", total_available) self.bot.stats.gauge("help.total.dormant", total_dormant) + @staticmethod + def is_claimant(member: discord.Member) -> bool: + """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`.""" if not message or not message.embeds: -- cgit v1.2.3 From 427c954903a62fe75aa22cf0fde9a52d2d6f2287 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 11:45:48 -0700 Subject: HelpChannels: clear roles when resetting permissions Claimants will have a special role that needs to be removed rather than using member overwrites for the category. --- bot/cogs/help_channels.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index d47a42ca6..5dc90ee8e 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -632,18 +632,16 @@ class HelpChannels(Scheduler, commands.Cog): await category.set_permissions(member, **permissions) async def reset_send_permissions(self) -> None: - """Reset send permissions for members with it set to False in the Available category.""" + """Reset send permissions in the Available category for claimants.""" log.trace("Resetting send permissions in the Available category.") + guild = self.bot.get_guild(constants.Guild.id) - for member, overwrite in self.available_category.overwrites.items(): - if isinstance(member, discord.Member) and overwrite.send_messages is False: + # TODO: replace with a persistent cache cause checking every member is quite slow + for member in guild.members: + if self.is_claimant(member): log.trace(f"Resetting send permissions for {member} ({member.id}).") - - # We don't use the permissions helper function here as we may have to reset multiple overwrites - # and we don't want to enforce the permissions synchronization in each iteration. - await self.available_category.set_permissions(member, overwrite=None) - - log.trace(f"Ensuring channels in `Help: Available` are synchronized after permissions reset.") + role = discord.Object(constants.Roles.help_cooldown) + await member.remove_roles(role) async def reset_claimant_send_permission(self, channel: discord.TextChannel) -> None: """Reset send permissions in the Available category for the help `channel` claimant.""" -- cgit v1.2.3 From efc778f87f0b4a6fb83007629aa5f6f868da564b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Apr 2020 09:18:26 -0700 Subject: HelpChannels: add/remove a cooldown role rather than using overwrites Overwrites had issues syncing with channels in the category. * Remove update_category_permissions; obsolete * Add constant for the cooldown role wrapped in a discord.Object --- bot/cogs/help_channels.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 5dc90ee8e..47e74a2e5 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -21,6 +21,7 @@ log = logging.getLogger(__name__) ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" MAX_CHANNELS_PER_CATEGORY = 50 +COOLDOWN_ROLE = discord.Object(constants.Roles.help_cooldown) AVAILABLE_TOPIC = """ This channel is available. Feel free to ask a question in order to claim this channel! @@ -624,13 +625,6 @@ class HelpChannels(Scheduler, commands.Cog): # be put in the queue. await self.move_to_available() - async def update_category_permissions( - self, category: discord.CategoryChannel, member: discord.Member, **permissions - ) -> None: - """Update the permissions of the given `member` for the given `category` with `permissions` passed.""" - log.trace(f"Updating permissions for `{member}` in `{category}` with {permissions}.") - await category.set_permissions(member, **permissions) - 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.") @@ -640,8 +634,7 @@ class HelpChannels(Scheduler, commands.Cog): for member in guild.members: if self.is_claimant(member): log.trace(f"Resetting send permissions for {member} ({member.id}).") - role = discord.Object(constants.Roles.help_cooldown) - await member.remove_roles(role) + await member.remove_roles(COOLDOWN_ROLE) async def reset_claimant_send_permission(self, channel: discord.TextChannel) -> None: """Reset send permissions in the Available category for the help `channel` claimant.""" @@ -649,11 +642,15 @@ class HelpChannels(Scheduler, commands.Cog): try: member = self.help_channel_claimants[channel] except KeyError: - log.trace(f"Channel #{channel.name} ({channel.id}) not in claimant cache, permissions unchanged.") + log.trace( + f"Channel #{channel.name} ({channel.id}) not in claimant cache, " + f"permissions unchanged." + ) return log.trace(f"Resetting send permissions for {member} ({member.id}).") - await self.update_category_permissions(self.available_category, member, overwrite=None) + await member.remove_roles(COOLDOWN_ROLE) + # Ignore missing task when claim cooldown has passed but the channel still isn't dormant. self.cancel_task(member.id, ignore_missing=True) @@ -668,14 +665,14 @@ class HelpChannels(Scheduler, commands.Cog): f"Revoking {member}'s ({member.id}) send message permissions in the Available category." ) - await self.update_category_permissions(self.available_category, member, send_messages=False) + await member.add_roles(COOLDOWN_ROLE) # Cancel the existing task, if any. # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner). self.cancel_task(member.id, ignore_missing=True) timeout = constants.HelpChannels.claim_minutes * 60 - callback = self.update_category_permissions(self.available_category, member, overwrite=None) + callback = member.remove_roles(COOLDOWN_ROLE) log.trace(f"Scheduling {member}'s ({member.id}) send message permissions to be reinstated.") self.schedule_task(member.id, TaskData(timeout, callback)) -- cgit v1.2.3 From 244b23a4d36f0117e7a385979a1b03e4534cffb4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Apr 2020 09:23:57 -0700 Subject: HelpChannels: add info about cooldown role & dormant cmd to docstring --- bot/cogs/help_channels.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 47e74a2e5..589342098 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -89,12 +89,15 @@ class HelpChannels(Scheduler, commands.Cog): * If there are no more dormant channels, the bot will automatically create a new one * If there are no dormant channels to move, helpers will be notified (see `notify()`) * When a channel becomes available, the dormant embed will be edited to show `AVAILABLE_MSG` + * User can only claim a channel at an interval `constants.HelpChannels.claim_minutes` + * To keep track of cooldowns, user which claimed a channel will have a temporary role In Use Category * Contains all channels which are occupied by someone needing help * Channel moves to dormant category after `constants.HelpChannels.idle_minutes` of being idle * Command can prematurely mark a channel as dormant + * Channel claimant is allowed to use the command * Allowed roles for the command are configurable with `constants.HelpChannels.cmd_whitelist` * When a channel becomes dormant, an embed with `DORMANT_MSG` will be sent -- cgit v1.2.3 From 96a736b037bb0cb5aef6a381520e15fdb50676dc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Apr 2020 09:27:27 -0700 Subject: HelpChannels: mention dormant cmd in available message embed Users should know they can close their own channels. --- bot/cogs/help_channels.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 589342098..149808473 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -40,8 +40,9 @@ channels in the Help: Available category. AVAILABLE_MSG = f""" This help channel is now **available**, which means that you can claim it by simply typing your \ question into it. Once claimed, the channel will move into the **Python Help: Occupied** category, \ -and will be yours until it has been inactive for {constants.HelpChannels.idle_minutes} minutes. When \ -that happens, it will be set to **dormant** and moved into the **Help: Dormant** category. +and will be yours until it has been inactive for {constants.HelpChannels.idle_minutes} minutes or \ +is closed manually with `!close`. When that happens, it will be set to **dormant** and moved into \ +the **Help: Dormant** category. You may claim a new channel once every {constants.HelpChannels.claim_minutes} minutes. If you \ currently cannot send a message in this channel, it means you are on cooldown and need to wait. -- cgit v1.2.3 From b209700d7e8d882b2ff3f4ca097c3644d089920c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Apr 2020 09:59:50 -0700 Subject: HelpChannels: fix role not resetting after dormant command Resetting permissions relied on getting the member from the cache, but the member was already removed from the cache prior to resetting the role. Now the member is passed directly rather than relying on the cache. --- bot/cogs/help_channels.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 149808473..b4fc901cc 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -230,7 +230,7 @@ class HelpChannels(Scheduler, commands.Cog): del self.help_channel_claimants[ctx.channel] with suppress(discord.errors.HTTPException, discord.errors.NotFound): - await self.reset_claimant_send_permission(ctx.channel) + await self.reset_claimant_send_permission(ctx.author) await self.move_to_dormant(ctx.channel, "command") self.cancel_task(ctx.channel.id) @@ -640,18 +640,8 @@ class HelpChannels(Scheduler, commands.Cog): log.trace(f"Resetting send permissions for {member} ({member.id}).") await member.remove_roles(COOLDOWN_ROLE) - async def reset_claimant_send_permission(self, channel: discord.TextChannel) -> None: - """Reset send permissions in the Available category for the help `channel` claimant.""" - log.trace(f"Attempting to find claimant for #{channel.name} ({channel.id}).") - try: - member = self.help_channel_claimants[channel] - except KeyError: - log.trace( - f"Channel #{channel.name} ({channel.id}) not in claimant cache, " - f"permissions unchanged." - ) - return - + async def reset_claimant_send_permission(self, member: discord.Member) -> None: + """Reset send permissions in the Available category for `member`.""" log.trace(f"Resetting send permissions for {member} ({member.id}).") await member.remove_roles(COOLDOWN_ROLE) -- cgit v1.2.3 From 2b844d8bfbd686f1a56f1efc00dcca4558698016 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Apr 2020 11:14:05 -0700 Subject: HelpChannels: handle errors when changing cooldown role A user may leave the guild before their role can be changed. Sometimes, there could also be role hierarchy issues or other network issues. It's not productive to halt everything and just dump these as exceptions to the loggers. The error handler provides a more graceful approach to these exceptions. * Add a wrapper function around `add_roles` & `remove_roles` which catches exceptions --- bot/cogs/help_channels.py | 47 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index b4fc901cc..c70cb6ffb 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -229,8 +229,9 @@ class HelpChannels(Scheduler, commands.Cog): with suppress(KeyError): del self.help_channel_claimants[ctx.channel] - with suppress(discord.errors.HTTPException, discord.errors.NotFound): - await self.reset_claimant_send_permission(ctx.author) + await self.remove_cooldown_role(ctx.author) + # Ignore missing task when cooldown has passed but the channel still isn't dormant. + self.cancel_task(ctx.author.id, ignore_missing=True) await self.move_to_dormant(ctx.channel, "command") self.cancel_task(ctx.channel.id) @@ -637,16 +638,38 @@ class HelpChannels(Scheduler, commands.Cog): # TODO: replace with a persistent cache cause checking every member is quite slow for member in guild.members: if self.is_claimant(member): - log.trace(f"Resetting send permissions for {member} ({member.id}).") - await member.remove_roles(COOLDOWN_ROLE) + await self.remove_cooldown_role(member) - async def reset_claimant_send_permission(self, member: discord.Member) -> None: - """Reset send permissions in the Available category for `member`.""" - log.trace(f"Resetting send permissions for {member} ({member.id}).") - await member.remove_roles(COOLDOWN_ROLE) + @classmethod + async def add_cooldown_role(cls, member: discord.Member) -> None: + """Add the help cooldown role to `member`.""" + log.trace(f"Adding cooldown role for {member} ({member.id}).") + await cls._change_cooldown_role(member, member.add_roles(COOLDOWN_ROLE)) - # Ignore missing task when claim cooldown has passed but the channel still isn't dormant. - self.cancel_task(member.id, ignore_missing=True) + @classmethod + async def remove_cooldown_role(cls, member: discord.Member) -> None: + """Remove the help cooldown role from `member`.""" + log.trace(f"Removing cooldown role for {member} ({member.id}).") + await cls._change_cooldown_role(member, member.remove_roles(COOLDOWN_ROLE)) + + @staticmethod + async def _change_cooldown_role(member: discord.Member, coro: t.Awaitable) -> None: + """ + Change `member`'s cooldown role via awaiting `coro` and handle errors. + + `coro` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. + """ + try: + await coro + except discord.NotFound: + log.debug(f"Failed to change role for {member} ({member.id}): member not found") + except discord.Forbidden: + log.debug( + f"Forbidden to change role for {member} ({member.id}); " + f"possibly due to role hierarchy" + ) + except discord.HTTPException as e: + log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}") async def revoke_send_permissions(self, member: discord.Member) -> None: """ @@ -659,14 +682,14 @@ class HelpChannels(Scheduler, commands.Cog): f"Revoking {member}'s ({member.id}) send message permissions in the Available category." ) - await member.add_roles(COOLDOWN_ROLE) + await self.add_cooldown_role(member) # Cancel the existing task, if any. # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner). self.cancel_task(member.id, ignore_missing=True) timeout = constants.HelpChannels.claim_minutes * 60 - callback = member.remove_roles(COOLDOWN_ROLE) + callback = self.remove_cooldown_role(member) log.trace(f"Scheduling {member}'s ({member.id}) send message permissions to be reinstated.") self.schedule_task(member.id, TaskData(timeout, callback)) -- 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 f48d32ff836bbfc239aa82f013cfb0687aa3defd Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Mon, 20 Apr 2020 18:58:57 +0100 Subject: Add statistics on whether a help session was closed with no input from anyone but the claimant --- bot/cogs/help_channels.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index e73bbdae5..060a010cc 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -133,6 +133,7 @@ class HelpChannels(Scheduler, commands.Cog): # Stats self.claim_times = {} + self.unanswered = {} def cog_unload(self) -> None: """Cancel the init task and scheduled tasks when the cog unloads.""" @@ -506,6 +507,12 @@ class HelpChannels(Scheduler, commands.Cog): in_use_time = datetime.now() - claimed self.bot.stats.timing("help.in_use_time", in_use_time) + if channel.id in self.unanswered: + if self.unanswered[channel.id]: + self.bot.stats.incr("help.sessions.unanswered") + else: + self.bot.stats.incr("help.sessions.answered") + log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.") log.trace(f"Sending dormant message for #{channel} ({channel.id}).") @@ -587,6 +594,13 @@ class HelpChannels(Scheduler, commands.Cog): return # Ignore messages sent by bots. channel = message.channel + if not self.is_in_category(channel, constants.Categories.help_in_use): + if channel.id in self.unanswered: + claimant_id = self.help_channel_claimants[channel].id + + if claimant_id != message.author.id: + self.unanswered[channel.id] = False + if not self.is_in_category(channel, constants.Categories.help_available): return # Ignore messages outside the Available category. @@ -612,6 +626,7 @@ class HelpChannels(Scheduler, commands.Cog): self.bot.stats.incr("help.claimed") self.claim_times[channel.id] = datetime.now() + self.unanswered[channel.id] = True log.trace(f"Releasing on_message lock for {message.id}.") -- cgit v1.2.3 From 96ef7f76ba24134b4688ca69a2287eb52a33a1e4 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Mon, 20 Apr 2020 19:01:24 +0100 Subject: Incorrect comparison, we need to check if we are in help_in_use, not out of it --- 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 060a010cc..c640c4d6f 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -594,7 +594,7 @@ class HelpChannels(Scheduler, commands.Cog): return # Ignore messages sent by bots. channel = message.channel - if not self.is_in_category(channel, constants.Categories.help_in_use): + if self.is_in_category(channel, constants.Categories.help_in_use): if channel.id in self.unanswered: claimant_id = self.help_channel_claimants[channel].id -- cgit v1.2.3 From 383f5a71e6c941eaa932db7017fb1be27efb0e95 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 20 Apr 2020 11:21:14 -0700 Subject: HelpChannels: tidy up log messages * Remove obsolete log message * Shorten a log message which was the only line in the entire module over 100 characters --- 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 c70cb6ffb..875eb5330 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -269,7 +269,7 @@ class HelpChannels(Scheduler, commands.Cog): log.trace(f"The clean name for `{channel}` is `{name}`") except ValueError: # If, for some reason, the channel name does not contain "help-" fall back gracefully - log.info(f"Can't get clean name as `{channel}` does not follow the `{prefix}` naming convention.") + log.info(f"Can't get clean name because `{channel}` isn't prefixed by `{prefix}`.") name = channel.name return name @@ -488,10 +488,6 @@ class HelpChannels(Scheduler, commands.Cog): topic=AVAILABLE_TOPIC, ) - log.trace( - f"Ensuring that all channels in `{self.available_category}` have " - f"synchronized permissions after moving `{channel}` into it." - ) self.report_stats() async def move_to_dormant(self, channel: discord.TextChannel, caller: str) -> None: -- cgit v1.2.3 From b05b70453b5fc9f79b9434a8d9f9e49db7837856 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 20 Apr 2020 11:44:05 -0700 Subject: HelpChannels: pass coroutine func instead to `_change_cooldown_role` This will allow `_change_cooldown_role` to handle the role argument rather than putting that burden on the callers. --- bot/cogs/help_channels.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 875eb5330..30ef56f56 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -67,6 +67,8 @@ AVAILABLE_EMOJI = "✅" IN_USE_EMOJI = "⌛" NAME_SEPARATOR = "|" +CoroutineFunc = t.Callable[..., t.Coroutine] + class TaskData(t.NamedTuple): """Data for a scheduled task.""" @@ -640,23 +642,23 @@ class HelpChannels(Scheduler, commands.Cog): async def add_cooldown_role(cls, member: discord.Member) -> None: """Add the help cooldown role to `member`.""" log.trace(f"Adding cooldown role for {member} ({member.id}).") - await cls._change_cooldown_role(member, member.add_roles(COOLDOWN_ROLE)) + await cls._change_cooldown_role(member, member.add_roles) @classmethod async def remove_cooldown_role(cls, member: discord.Member) -> None: """Remove the help cooldown role from `member`.""" log.trace(f"Removing cooldown role for {member} ({member.id}).") - await cls._change_cooldown_role(member, member.remove_roles(COOLDOWN_ROLE)) + await cls._change_cooldown_role(member, member.remove_roles) @staticmethod - async def _change_cooldown_role(member: discord.Member, coro: t.Awaitable) -> None: + async def _change_cooldown_role(member: discord.Member, coro_func: CoroutineFunc) -> None: """ - Change `member`'s cooldown role via awaiting `coro` and handle errors. + Change `member`'s cooldown role via awaiting `coro_func` and handle errors. - `coro` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. + `coro_func` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. """ try: - await coro + await coro_func(COOLDOWN_ROLE) except discord.NotFound: log.debug(f"Failed to change role for {member} ({member.id}): member not found") except discord.Forbidden: -- cgit v1.2.3 From 7bb69f8ef15f03d355dc114181ce27df5aee7cfd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 20 Apr 2020 11:58:43 -0700 Subject: HelpChannels: check if the help cooldown role exists A NotFound error can be misleading since it may apply to the member or the role. The log message was not simply updated because each of the scenarios need to have different log levels: missing members is a normal thing but an invalid role is not. --- bot/cogs/help_channels.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 30ef56f56..5a1495a4d 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -21,7 +21,6 @@ log = logging.getLogger(__name__) ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" MAX_CHANNELS_PER_CATEGORY = 50 -COOLDOWN_ROLE = discord.Object(constants.Roles.help_cooldown) AVAILABLE_TOPIC = """ This channel is available. Feel free to ask a question in order to claim this channel! @@ -638,27 +637,30 @@ class HelpChannels(Scheduler, commands.Cog): if self.is_claimant(member): await self.remove_cooldown_role(member) - @classmethod - async def add_cooldown_role(cls, member: discord.Member) -> None: + async def add_cooldown_role(self, member: discord.Member) -> None: """Add the help cooldown role to `member`.""" log.trace(f"Adding cooldown role for {member} ({member.id}).") - await cls._change_cooldown_role(member, member.add_roles) + await self._change_cooldown_role(member, member.add_roles) - @classmethod - async def remove_cooldown_role(cls, member: discord.Member) -> None: + async def remove_cooldown_role(self, member: discord.Member) -> None: """Remove the help cooldown role from `member`.""" log.trace(f"Removing cooldown role for {member} ({member.id}).") - await cls._change_cooldown_role(member, member.remove_roles) + await self._change_cooldown_role(member, member.remove_roles) - @staticmethod - async def _change_cooldown_role(member: discord.Member, coro_func: CoroutineFunc) -> None: + async def _change_cooldown_role(self, member: discord.Member, coro_func: CoroutineFunc) -> None: """ Change `member`'s cooldown role via awaiting `coro_func` and handle errors. `coro_func` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. """ + guild = self.bot.get_guild(constants.Guild.id) + role = guild.get_role(constants.Roles.help_cooldown) + if role is None: + log.warning(f"Help cooldown role ({constants.Roles.help_cooldown}) could not be found!") + return + try: - await coro_func(COOLDOWN_ROLE) + await coro_func(role) except discord.NotFound: log.debug(f"Failed to change role for {member} ({member.id}): member not found") except discord.Forbidden: -- cgit v1.2.3 From d5aef24b212814ad63f3f01069d3c375625af858 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Mon, 20 Apr 2020 21:12:38 +0100 Subject: Add different emoji for different channel statuses (in use answered/unanswered) --- bot/cogs/help_channels.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index c640c4d6f..815a5997a 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -62,7 +62,8 @@ through our guide for [asking a good question]({ASKING_GUIDE_URL}). """ AVAILABLE_EMOJI = "✅" -IN_USE_EMOJI = "⌛" +IN_USE_ANSWERED_EMOJI = "⌛" +IN_USE_UNANSWERED_EMOJI = "⏳" NAME_SEPARATOR = "|" @@ -528,7 +529,7 @@ class HelpChannels(Scheduler, commands.Cog): log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") await channel.edit( - name=f"{IN_USE_EMOJI}{NAME_SEPARATOR}{self.get_clean_channel_name(channel)}", + name=f"{IN_USE_UNANSWERED_EMOJI}{NAME_SEPARATOR}{self.get_clean_channel_name(channel)}", category=self.in_use_category, sync_permissions=True, topic=IN_USE_TOPIC, @@ -601,6 +602,10 @@ class HelpChannels(Scheduler, commands.Cog): if claimant_id != message.author.id: self.unanswered[channel.id] = False + await channel.edit( + name=f"{IN_USE_ANSWERED_EMOJI}{NAME_SEPARATOR}{self.get_clean_channel_name(channel)}" + ) + if not self.is_in_category(channel, constants.Categories.help_available): return # Ignore messages outside the Available category. -- cgit v1.2.3 From 2b8bc72dacab82edc82111ab0cd8dbc6d3e724d6 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Mon, 20 Apr 2020 21:28:27 +0100 Subject: Extra documentation + split out to separate function --- bot/cogs/help_channels.py | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 815a5997a..3c41673b4 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -133,8 +133,14 @@ class HelpChannels(Scheduler, commands.Cog): self.init_task = self.bot.loop.create_task(self.init_cog()) # Stats - self.claim_times = {} - self.unanswered = {} + + # This dictionary maps a help channel to the time it was claimed + self.claim_times: t.Dict[int, datetime] = {} + + # This dictionary maps a help channel to whether it has had any + # activity other than the original claimant. True being no other + # activity and False being other activity. + self.unanswered: t.Dict[int, bool] = {} def cog_unload(self) -> None: """Cancel the init task and scheduled tasks when the cog unloads.""" @@ -588,24 +594,36 @@ class HelpChannels(Scheduler, commands.Cog): # Handle it here cause this feature isn't critical for the functionality of the system. log.exception("Failed to send notification about lack of dormant channels!") - @commands.Cog.listener() - async def on_message(self, message: discord.Message) -> None: - """Move an available channel to the In Use category and replace it with a dormant one.""" - if message.author.bot: - return # Ignore messages sent by bots. - + async def check_for_answer(self, message: discord.Message) -> None: + """Checks for whether new content in a help channel comes from non-claimants.""" channel = message.channel + + # Confirm the channel is an in use help channel if self.is_in_category(channel, constants.Categories.help_in_use): + # Check if there is an entry in unanswered (does not persist across restarts) if channel.id in self.unanswered: claimant_id = self.help_channel_claimants[channel].id + # Check the message did not come from the claimant if claimant_id != message.author.id: + # Mark the channel as answered self.unanswered[channel.id] = False + # Change the emoji in the channel name to signify activity await channel.edit( name=f"{IN_USE_ANSWERED_EMOJI}{NAME_SEPARATOR}{self.get_clean_channel_name(channel)}" ) + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Move an available channel to the In Use category and replace it with a dormant one.""" + if message.author.bot: + return # Ignore messages sent by bots. + + channel = message.channel + + await self.check_for_answer(message) + if not self.is_in_category(channel, constants.Categories.help_available): return # Ignore messages outside the Available category. -- cgit v1.2.3 From 7b0cba07953f7a74a0a0b57dfb5f38299adcdccd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 20 Apr 2020 13:47:12 -0700 Subject: HelpChannels: rename dormant command to close People are more familiar with the "close" alias than its actual name, "dormant". "close" also feels more natural. --- bot/cogs/help_channels.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 5a1495a4d..75f907602 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -215,8 +215,8 @@ class HelpChannels(Scheduler, commands.Cog): return role_check - @commands.command(name="dormant", aliases=["close"], enabled=False) - async def dormant_command(self, ctx: commands.Context) -> None: + @commands.command(name="close", aliases=["dormant"], enabled=False) + async def close_command(self, ctx: commands.Context) -> None: """ Make the current in-use help channel dormant. @@ -224,7 +224,7 @@ class HelpChannels(Scheduler, commands.Cog): delete the message that invoked this, and reset the send permissions cooldown for the user who started the session. """ - log.trace("dormant command invoked; checking if the channel is in-use.") + log.trace("close command invoked; checking if the channel is in-use.") if ctx.channel.category == self.in_use_category: if await self.dormant_check(ctx): with suppress(KeyError): @@ -400,7 +400,7 @@ class HelpChannels(Scheduler, commands.Cog): # The ready event wasn't used because channels could change categories between the time # the command is invoked and the cog is ready (e.g. if move_idle_channel wasn't called yet). # This may confuse users. So would potentially long delays for the cog to become ready. - self.dormant_command.enabled = True + self.close_command.enabled = True await self.init_available() -- cgit v1.2.3 From b842bfe9a1f5b811bc9cbfa0e354a01bbb02152e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 20 Apr 2020 14:09:31 -0700 Subject: HelpChannels: add logging to answered check --- bot/cogs/help_channels.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 3c41673b4..9d7328739 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -597,6 +597,7 @@ class HelpChannels(Scheduler, commands.Cog): async def check_for_answer(self, message: discord.Message) -> None: """Checks for whether new content in a help channel comes from non-claimants.""" channel = message.channel + log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") # Confirm the channel is an in use help channel if self.is_in_category(channel, constants.Categories.help_in_use): @@ -610,9 +611,9 @@ class HelpChannels(Scheduler, commands.Cog): self.unanswered[channel.id] = False # Change the emoji in the channel name to signify activity - await channel.edit( - name=f"{IN_USE_ANSWERED_EMOJI}{NAME_SEPARATOR}{self.get_clean_channel_name(channel)}" - ) + log.trace(f"#{channel} ({channel.id}) has been answered; changing its emoji") + name = self.get_clean_channel_name(channel) + await channel.edit(name=f"{IN_USE_ANSWERED_EMOJI}{NAME_SEPARATOR}{name}") @commands.Cog.listener() async def on_message(self, message: discord.Message) -> None: -- 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 ed80b91c36bfa397634a64f7881655454fc9557f Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Tue, 21 Apr 2020 14:12:48 +0100 Subject: Fix category cache issue --- bot/cogs/help_channels.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 9d7328739..a61f30deb 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -276,13 +276,12 @@ class HelpChannels(Scheduler, commands.Cog): return name - @staticmethod - def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: + def get_category_channels(self, category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: """Yield the text channels of the `category` in an unsorted manner.""" log.trace(f"Getting text channels in the category '{category}' ({category.id}).") # This is faster than using category.channels because the latter sorts them. - for channel in category.guild.channels: + for channel in self.bot.get_guild(constants.Guild.id).channels: if channel.category_id == category.id and isinstance(channel, discord.TextChannel): yield channel -- cgit v1.2.3 From 6fe18c66c5cb6adcb89a40d33e5ce078331dcc04 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 22 Apr 2020 13:04:22 -0700 Subject: Use selector event loop on Windows aiodns requires the selector event loop for asyncio. In Python 3.8, the default event loop for Windows was changed to proactor. To fix this, the event loop is explicitly set to selector. --- bot/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bot/__init__.py b/bot/__init__.py index 2dd4af225..4131b69e9 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -1,3 +1,4 @@ +import asyncio import logging import os import sys @@ -59,3 +60,8 @@ coloredlogs.install(logger=root_log, stream=sys.stdout) logging.getLogger("discord").setLevel(logging.WARNING) logging.getLogger("websockets").setLevel(logging.WARNING) logging.getLogger(__name__) + + +# On Windows, the selector event loop is required for aiodns. +if os.name == "nt": + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) -- cgit v1.2.3 From 956b63c4d60ed0576e6873879b458edf93a539b3 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 23 Apr 2020 13:32:22 +0200 Subject: Simplify free tag --- bot/resources/tags/free.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/resources/tags/free.md b/bot/resources/tags/free.md index 6d0f3618a..cbbdab66e 100644 --- a/bot/resources/tags/free.md +++ b/bot/resources/tags/free.md @@ -1,6 +1,5 @@ -**How to claim a channel** +**W have a new help channel system!** -We recently moved to a new help channel system. There are always 2 available help channels waiting to be claimed in the **<#691405807388196926>**. In order to claim one, simply start typing your question into one of these channels. Once your question has been posted, you have claimed this channel, and the channel will be moved down to the **<#696958401460043776>**. -If you're unable to type into these channels, this means you're currently on cooldown. In order to prevent someone from claiming all the channels for themselves, we only allow someone to claim a new help channel every 15 minutes. However, if you close your help channel using the `!dormant` command, this cooldown is reset early. +We recently moved to a new help channel system. You can now use any channel in the **<#691405807388196926>** category to ask your question. For more information, check out [our website](https://pythondiscord.com/pages/resources/guides/help-channels/). -- cgit v1.2.3 From 0a935a4d8841e696209f93899682969f11296982 Mon Sep 17 00:00:00 2001 From: kwzrd <44734341+kwzrd@users.noreply.github.com> Date: Thu, 23 Apr 2020 13:03:03 +0100 Subject: Free tag: fix typo in header --- bot/resources/tags/free.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/free.md b/bot/resources/tags/free.md index cbbdab66e..582cca9da 100644 --- a/bot/resources/tags/free.md +++ b/bot/resources/tags/free.md @@ -1,4 +1,4 @@ -**W have a new help channel system!** +**We have a new help channel system!** We recently moved to a new help channel system. You can now use any channel in the **<#691405807388196926>** category to ask your question. -- cgit v1.2.3 From 1140e9690644e46196a1c8cad900272ffb3ae09a Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 20 Apr 2020 18:46:30 +0200 Subject: Replace `in_channel` decorator by `in_whitelisted_context` The `in_channel` decorator that served as a factory for `in_channel` checks was replaced by the broaded `in_whitelisted_context` decorator. This means that we can now whitelist commands using channel IDs, category IDs, and/or role IDs. The whitelists will be applied in an "OR" fashion, meaning that as soon as some part of the context happens to be whitelisted, the `predicate` check the decorator produces will return `True`. To reflect that this is now a broader decorator that checks for a whitelisted *context* (as opposed to just whitelisted channels), the exception the predicate raises has been changed to `InWhitelistedContextCheckFailure` to reflect the broader scope of the decorator. I've updated all the commands that used the previous version, `in_channel`, to use the replacement. --- bot/cogs/error_handler.py | 6 +-- bot/cogs/information.py | 10 +++-- bot/cogs/snekbox.py | 11 ++++- bot/cogs/utils.py | 8 +++- bot/cogs/verification.py | 18 +++++--- bot/decorators.py | 84 +++++++++++++++++++++++++------------- tests/bot/cogs/test_information.py | 4 +- 7 files changed, 94 insertions(+), 47 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index dae283c6a..3f56a9798 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -9,7 +9,7 @@ from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels from bot.converters import TagNameConverter -from bot.decorators import InChannelCheckFailure +from bot.decorators import InWhitelistedContextCheckFailure log = logging.getLogger(__name__) @@ -202,7 +202,7 @@ class ErrorHandler(Cog): * BotMissingRole * BotMissingAnyRole * NoPrivateMessage - * InChannelCheckFailure + * InWhitelistedContextCheckFailure """ bot_missing_errors = ( errors.BotMissingPermissions, @@ -215,7 +215,7 @@ class ErrorHandler(Cog): await ctx.send( f"Sorry, it looks like I don't have the permissions or roles I need to do that." ) - elif isinstance(e, (InChannelCheckFailure, errors.NoPrivateMessage)): + elif isinstance(e, (InWhitelistedContextCheckFailure, errors.NoPrivateMessage)): ctx.bot.stats.incr("errors.wrong_channel_or_dm_error") await ctx.send(e) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 7921a4932..6b3fc0c96 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -12,7 +12,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.decorators import InWhitelistedContextCheckFailure, in_whitelisted_context, 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 @@ -152,7 +152,7 @@ class Information(Cog): # Non-staff may only do this in #bot-commands if not with_role_check(ctx, *constants.STAFF_ROLES): if not ctx.channel.id == constants.Channels.bot_commands: - raise InChannelCheckFailure(constants.Channels.bot_commands) + raise InWhitelistedContextCheckFailure(constants.Channels.bot_commands) embed = await self.create_user_embed(ctx, user) @@ -331,7 +331,11 @@ class Information(Cog): @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) @group(invoke_without_command=True) - @in_channel(constants.Channels.bot_commands, bypass_roles=constants.STAFF_ROLES) + @in_whitelisted_context( + whitelisted_channels=(constants.Channels.bot_commands,), + whitelisted_roles=constants.STAFF_ROLES, + redirect_channel=constants.Channels.bot_commands, + ) async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None: """Shows information about the raw API response.""" # I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 315383b12..8827cb585 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -13,7 +13,7 @@ from discord.ext.commands import Cog, Context, command, guild_only from bot.bot import Bot from bot.constants import Channels, Roles, URLs -from bot.decorators import in_channel +from bot.decorators import in_whitelisted_context from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) @@ -38,6 +38,9 @@ RAW_CODE_REGEX = re.compile( ) MAX_PASTE_LEN = 1000 + +# `!eval` command whitelists +EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric) EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners) SIGKILL = 9 @@ -265,7 +268,11 @@ class Snekbox(Cog): @command(name="eval", aliases=("e",)) @guild_only() - @in_channel(Channels.bot_commands, hidden_channels=(Channels.esoteric,), bypass_roles=EVAL_ROLES) + @in_whitelisted_context( + whitelisted_channels=EVAL_CHANNELS, + whitelisted_roles=EVAL_ROLES, + redirect_channel=Channels.bot_commands, + ) async def eval_command(self, ctx: Context, *, code: str = None) -> None: """ Run Python code and get the results. diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 3ed471bbf..234ec514d 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -13,7 +13,7 @@ from discord.ext.commands import BadArgument, Cog, Context, command from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES, Mention, STAFF_ROLES -from bot.decorators import in_channel, with_role +from bot.decorators import in_whitelisted_context, with_role from bot.utils.time import humanize_delta log = logging.getLogger(__name__) @@ -118,7 +118,11 @@ class Utils(Cog): await ctx.message.channel.send(embed=pep_embed) @command() - @in_channel(Channels.bot_commands, bypass_roles=STAFF_ROLES) + @in_whitelisted_context( + whitelisted_channels=(Channels.bot_commands,), + whitelisted_roles=STAFF_ROLES, + redirect_channel=Channels.bot_commands, + ) async def charinfo(self, ctx: Context, *, characters: str) -> None: """Shows you information on up to 25 unicode characters.""" match = re.match(r"<(a?):(\w+):(\d+)>", characters) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index b0a493e68..040f52fbf 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -9,7 +9,7 @@ from discord.ext.commands import Cog, Context, command from bot import constants from bot.bot import Bot from bot.cogs.moderation import ModLog -from bot.decorators import InChannelCheckFailure, in_channel, without_role +from bot.decorators import InWhitelistedContextCheckFailure, in_whitelisted_context, without_role from bot.utils.checks import without_role_check log = logging.getLogger(__name__) @@ -122,7 +122,7 @@ class Verification(Cog): @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True) @without_role(constants.Roles.verified) - @in_channel(constants.Channels.verification) + @in_whitelisted_context(whitelisted_channels=(constants.Channels.verification,)) async def accept_command(self, ctx: Context, *_) -> None: # We don't actually care about the args """Accept our rules and gain access to the rest of the server.""" log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.") @@ -138,7 +138,10 @@ class Verification(Cog): await ctx.message.delete() @command(name='subscribe') - @in_channel(constants.Channels.bot_commands) + @in_whitelisted_context( + whitelisted_channels=(constants.Channels.bot_commands,), + redirect_channel=constants.Channels.bot_commands, + ) async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args """Subscribe to announcement notifications by assigning yourself the role.""" has_role = False @@ -162,7 +165,10 @@ class Verification(Cog): ) @command(name='unsubscribe') - @in_channel(constants.Channels.bot_commands) + @in_whitelisted_context( + whitelisted_channels=(constants.Channels.bot_commands,), + redirect_channel=constants.Channels.bot_commands, + ) async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args """Unsubscribe from announcement notifications by removing the role from yourself.""" has_role = False @@ -187,8 +193,8 @@ class Verification(Cog): # This cannot be static (must have a __func__ attribute). async def cog_command_error(self, ctx: Context, error: Exception) -> None: - """Check for & ignore any InChannelCheckFailure.""" - if isinstance(error, InChannelCheckFailure): + """Check for & ignore any InWhitelistedContextCheckFailure.""" + if isinstance(error, InWhitelistedContextCheckFailure): error.handled = True @staticmethod diff --git a/bot/decorators.py b/bot/decorators.py index 2d18eaa6a..149564d18 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -3,7 +3,7 @@ import random from asyncio import Lock, sleep from contextlib import suppress from functools import wraps -from typing import Callable, Container, Union +from typing import Callable, Container, Optional, Union from weakref import WeakValueDictionary from discord import Colour, Embed, Member @@ -17,48 +17,74 @@ from bot.utils.checks import with_role_check, without_role_check log = logging.getLogger(__name__) -class InChannelCheckFailure(CheckFailure): - """Raised when a check fails for a message being sent in a whitelisted channel.""" +class InWhitelistedContextCheckFailure(CheckFailure): + """Raised when the `in_whitelist` check fails.""" - def __init__(self, *channels: int): - self.channels = channels - channels_str = ', '.join(f"<#{c_id}>" for c_id in channels) + def __init__(self, redirect_channel: Optional[int] = None): + error_message = "Sorry, but you are not allowed to use that command here." - super().__init__(f"Sorry, but you may only use this command within {channels_str}.") + if redirect_channel: + error_message += f" Please use the <#{redirect_channel}> channel instead." + super().__init__(error_message) + + +def in_whitelisted_context( + *, + whitelisted_channels: Container[int] = (), + whitelisted_categories: Container[int] = (), + whitelisted_roles: Container[int] = (), + redirect_channel: Optional[int] = None, -def in_channel( - *channels: int, - hidden_channels: Container[int] = None, - bypass_roles: Container[int] = None ) -> Callable: """ - Checks that the message is in a whitelisted channel or optionally has a bypass role. + Check if a command was issued in a whitelisted context. + + The whitelists that can be provided are: - Hidden channels are channels which will not be displayed in the InChannelCheckFailure error - message. + - `channels`: a container with channel ids for whitelisted channels + - `categories`: a container with category ids for whitelisted categories + - `roles`: a container with with role ids for whitelisted roles + + An optional `redirect_channel` can be provided to redirect users that are not + authorized to use the command in the current context. If no such channel is + provided, the users are simply told that they are not authorized to use the + command. """ - hidden_channels = hidden_channels or [] - bypass_roles = bypass_roles or [] + if redirect_channel and redirect_channel not in whitelisted_channels: + # It does not make sense for the channel whitelist to not contain the redirection + # channel (if provided). That's why we add the redirection channel to the `channels` + # container if it's not already in it. As we allow any container type to be passed, + # we first create a tuple in order to safely add the redirection channel. + # + # Note: It's possible for the redirect channel to be in a whitelisted category, but + # there's no easy way to check that and as a channel can easily be moved in and out of + # categories, it's probably not wise to rely on its category in any case. + whitelisted_channels = tuple(whitelisted_channels) + (redirect_channel,) def predicate(ctx: Context) -> bool: - """In-channel checker predicate.""" - if ctx.channel.id in channels or ctx.channel.id in hidden_channels: - log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " - f"The command was used in a whitelisted channel.") + """Check if a command was issued in a whitelisted context.""" + if whitelisted_channels and ctx.channel.id in whitelisted_channels: + log.trace(f"{ctx.author} may use the `{ctx.command.name}` command as they are in a whitelisted channel.") return True - if bypass_roles: - if any(r.id in bypass_roles for r in ctx.author.roles): - log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " - f"The command was not used in a whitelisted channel, " - f"but the author had a role to bypass the in_channel check.") - return True + # Only check the category id if we have a category whitelist and the channel has a `category_id` + if ( + whitelisted_categories + and hasattr(ctx.channel, "category_id") + and ctx.channel.category_id in whitelisted_categories + ): + log.trace(f"{ctx.author} may use the `{ctx.command.name}` command as they are in a whitelisted category.") + return True - log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " - f"The in_channel check failed.") + # Only check the roles whitelist if we have one and ensure the author's roles attribute returns + # an iterable to prevent breakage in DM channels (for if we ever decide to enable commands there). + if whitelisted_roles and any(r.id in whitelisted_roles for r in getattr(ctx.author, "roles", ())): + log.trace(f"{ctx.author} may use the `{ctx.command.name}` command as they have a whitelisted role.") + return True - raise InChannelCheckFailure(*channels) + log.trace(f"{ctx.author} may not use the `{ctx.command.name}` command within this context.") + raise InWhitelistedContextCheckFailure(redirect_channel) return commands.check(predicate) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 3c26374f5..4a36fe030 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -7,7 +7,7 @@ import discord from bot import constants from bot.cogs import information -from bot.decorators import InChannelCheckFailure +from bot.decorators import InWhitelistedContextCheckFailure from tests import helpers @@ -525,7 +525,7 @@ class UserCommandTests(unittest.TestCase): ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=100)) msg = "Sorry, but you may only use this command within <#50>." - with self.assertRaises(InChannelCheckFailure, msg=msg): + with self.assertRaises(InWhitelistedContextCheckFailure, msg=msg): asyncio.run(self.cog.user_info.callback(self.cog, ctx)) @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) -- cgit v1.2.3 From 00291d7d5f859e4131cb5c94541a90f80f358376 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 20 Apr 2020 18:53:31 +0200 Subject: Remove vestigial kwargs from MockTextChannel.__init__ --- tests/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/helpers.py b/tests/helpers.py index 8e13f0f28..9001deedf 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -315,7 +315,7 @@ class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): """ spec_set = channel_instance - def __init__(self, name: str = 'channel', channel_id: int = 1, **kwargs) -> None: + def __init__(self, **kwargs) -> None: default_kwargs = {'id': next(self.discord_id), 'name': 'channel', 'guild': MockGuild()} super().__init__(**collections.ChainMap(kwargs, default_kwargs)) -- cgit v1.2.3 From 57e69925af9a941dfe32acc0431a9699eda027f5 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 20 Apr 2020 18:57:12 +0200 Subject: Add tests for `in_whitelisted_context` decorator I have added tests for the new `in_whitelisted_context` decorator. They work by calling the decorator with different kwargs to generate a specific predicate callable. That callable is then called to assess if it comes to the right conclusion. --- tests/bot/test_decorators.py | 115 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 tests/bot/test_decorators.py diff --git a/tests/bot/test_decorators.py b/tests/bot/test_decorators.py new file mode 100644 index 000000000..fae7c0c52 --- /dev/null +++ b/tests/bot/test_decorators.py @@ -0,0 +1,115 @@ +import collections +import unittest +import unittest.mock + +from bot.decorators import InWhitelistedContextCheckFailure, in_whitelisted_context +from tests import helpers + + +WhitelistedContextTestCase = collections.namedtuple("WhitelistedContextTestCase", ("kwargs", "ctx")) + + +class InWhitelistedContextTests(unittest.TestCase): + """Tests for the `in_whitelisted_context` check.""" + + @classmethod + def setUpClass(cls): + """Set up helpers that only need to be defined once.""" + cls.bot_commands = helpers.MockTextChannel(id=123456789, category_id=123456) + cls.help_channel = helpers.MockTextChannel(id=987654321, category_id=987654) + cls.non_whitelisted_channel = helpers.MockTextChannel(id=666666) + + cls.non_staff_member = helpers.MockMember() + cls.staff_role = helpers.MockRole(id=121212) + cls.staff_member = helpers.MockMember(roles=(cls.staff_role,)) + + cls.whitelisted_channels = (cls.bot_commands.id,) + cls.whitelisted_categories = (cls.help_channel.category_id,) + cls.whitelisted_roles = (cls.staff_role.id,) + + def test_predicate_returns_true_for_whitelisted_context(self): + """The predicate should return `True` if a whitelisted context was passed to it.""" + test_cases = ( + # Commands issued in whitelisted channels by members without whitelisted roles + WhitelistedContextTestCase( + kwargs={"whitelisted_channels": self.whitelisted_channels}, + ctx=helpers.MockContext(channel=self.bot_commands, author=self.non_staff_member) + ), + # `redirect_channel` should be added implicitly to the `whitelisted_channels` + WhitelistedContextTestCase( + kwargs={"redirect_channel": self.bot_commands.id}, + ctx=helpers.MockContext(channel=self.bot_commands, author=self.non_staff_member) + ), + + # Commands issued in a whitelisted category by members without whitelisted roles + WhitelistedContextTestCase( + kwargs={"whitelisted_categories": self.whitelisted_categories}, + ctx=helpers.MockContext(channel=self.help_channel, author=self.non_staff_member) + ), + + # Command issued by a staff member in a non-whitelisted channel/category + WhitelistedContextTestCase( + kwargs={"whitelisted_roles": self.whitelisted_roles}, + ctx=helpers.MockContext(channel=self.non_whitelisted_channel, author=self.staff_member) + ), + + # With all kwargs provided + WhitelistedContextTestCase( + kwargs={ + "whitelisted_channels": self.whitelisted_channels, + "whitelisted_categories": self.whitelisted_categories, + "whitelisted_roles": self.whitelisted_roles, + "redirect_channel": self.bot_commands, + }, + ctx=helpers.MockContext(channel=self.help_channel, author=self.staff_member) + ), + ) + + for test_case in test_cases: + # patch `commands.check` with a no-op lambda that just returns the predicate passed to it + # so we can test the predicate that was generated from the specified kwargs. + with unittest.mock.patch("bot.decorators.commands.check", new=lambda predicate: predicate): + predicate = in_whitelisted_context(**test_case.kwargs) + + with self.subTest(test_case=test_case): + self.assertTrue(predicate(test_case.ctx)) + + def test_predicate_raises_exception_for_non_whitelisted_context(self): + """The predicate should raise `InWhitelistedContextCheckFailure` for a non-whitelisted context.""" + test_cases = ( + # Failing check with `redirect_channel` + WhitelistedContextTestCase( + kwargs={ + "whitelisted_categories": self.whitelisted_categories, + "whitelisted_channels": self.whitelisted_channels, + "whitelisted_roles": self.whitelisted_roles, + "redirect_channel": self.bot_commands.id, + }, + ctx=helpers.MockContext(channel=self.non_whitelisted_channel, author=self.non_staff_member) + ), + + # Failing check without `redirect_channel` + WhitelistedContextTestCase( + kwargs={ + "whitelisted_categories": self.whitelisted_categories, + "whitelisted_channels": self.whitelisted_channels, + "whitelisted_roles": self.whitelisted_roles, + }, + ctx=helpers.MockContext(channel=self.non_whitelisted_channel, author=self.non_staff_member) + ), + ) + + for test_case in test_cases: + # Create expected exception message based on whether or not a redirect channel was provided + expected_message = "Sorry, but you are not allowed to use that command here." + if test_case.kwargs.get("redirect_channel"): + expected_message += f" Please use the <#{test_case.kwargs['redirect_channel']}> channel instead." + + # patch `commands.check` with a no-op lambda that just returns the predicate passed to it + # so we can test the predicate that was generated from the specified kwargs. + with unittest.mock.patch("bot.decorators.commands.check", new=lambda predicate: predicate): + predicate = in_whitelisted_context(**test_case.kwargs) + + with self.subTest(test_case=test_case): + with self.assertRaises(InWhitelistedContextCheckFailure, msg=expected_message): + predicate(test_case.ctx) -- cgit v1.2.3 From 092474487d75ef6430e533b85fe386d837fbf3a6 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 20 Apr 2020 19:00:41 +0200 Subject: Allow `!eval` in help channel categories As help conversations now take place in their own, dedicated channels, there's no longer a pressing need to restrict the `!eval` command in help channels for regular members. As the command can be a valuable tool in explaining and teaching Python, we've therefore chosen to allow it in channels in `Help: Available` and `Help: Occupied` catagories. --- bot/cogs/snekbox.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 8827cb585..4999074b6 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -12,7 +12,7 @@ from discord import HTTPException, Message, NotFound, Reaction, User from discord.ext.commands import Cog, Context, command, guild_only from bot.bot import Bot -from bot.constants import Channels, Roles, URLs +from bot.constants import Categories, Channels, Roles, URLs from bot.decorators import in_whitelisted_context from bot.utils.messages import wait_for_deletion @@ -41,6 +41,7 @@ MAX_PASTE_LEN = 1000 # `!eval` command whitelists EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric) +EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use) EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners) SIGKILL = 9 @@ -270,6 +271,7 @@ class Snekbox(Cog): @guild_only() @in_whitelisted_context( whitelisted_channels=EVAL_CHANNELS, + whitelisted_categories=EVAL_CATEGORIES, whitelisted_roles=EVAL_ROLES, redirect_channel=Channels.bot_commands, ) -- cgit v1.2.3 From b20bb7471b8d1d01f217f0620f8597bf1bae4456 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Thu, 23 Apr 2020 15:51:58 +0200 Subject: Simplify `in_whitelisted_context` decorator API The API of the `in_whitelisted_context` decorator was a bit clunky: - The long parameter names frequently required multiline decorators - Despite `#bot-commands` being the defacto default, it needed to be passed - The name of the function, `in_whitelisted_context` is fairly long in itself To shorten the call length of the decorator, the parameter names were shortened by dropping the `whitelisted_` prefix. This means that the parameter names are now just `channels`, `categories`, and `roles`. This already means that all current usages of the decorator are reduced to one line. In addition, `#bot-commands` has now been made the default redirect channel for the decorator. This means that if no `redirect` was passed, users will be redirected to `bot-commands` to use the command. If needed, `None` (or any falsey value) can be passed to disable redirection. Passing another channel id will trigger that channel to be used as the redirection target instead of bot-commands. Finally, the name of the decorator was shortened to `in_whitelist`, which already communicates what it is supposed to do. --- bot/cogs/error_handler.py | 6 ++-- bot/cogs/information.py | 10 ++----- bot/cogs/snekbox.py | 9 ++---- bot/cogs/utils.py | 8 ++--- bot/cogs/verification.py | 18 ++++-------- bot/decorators.py | 49 +++++++++++++++---------------- tests/bot/cogs/test_information.py | 4 +-- tests/bot/test_decorators.py | 60 +++++++++++++++++++------------------- 8 files changed, 72 insertions(+), 92 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 3f56a9798..b2f4c59f6 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -9,7 +9,7 @@ from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels from bot.converters import TagNameConverter -from bot.decorators import InWhitelistedContextCheckFailure +from bot.decorators import InWhitelistCheckFailure log = logging.getLogger(__name__) @@ -202,7 +202,7 @@ class ErrorHandler(Cog): * BotMissingRole * BotMissingAnyRole * NoPrivateMessage - * InWhitelistedContextCheckFailure + * InWhitelistCheckFailure """ bot_missing_errors = ( errors.BotMissingPermissions, @@ -215,7 +215,7 @@ class ErrorHandler(Cog): await ctx.send( f"Sorry, it looks like I don't have the permissions or roles I need to do that." ) - elif isinstance(e, (InWhitelistedContextCheckFailure, errors.NoPrivateMessage)): + elif isinstance(e, (InWhitelistCheckFailure, errors.NoPrivateMessage)): ctx.bot.stats.incr("errors.wrong_channel_or_dm_error") await ctx.send(e) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 6b3fc0c96..4eb36c340 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -12,7 +12,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.decorators import InWhitelistedContextCheckFailure, in_whitelisted_context, with_role +from bot.decorators import InWhitelistCheckFailure, in_whitelist, 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 @@ -152,7 +152,7 @@ class Information(Cog): # Non-staff may only do this in #bot-commands if not with_role_check(ctx, *constants.STAFF_ROLES): if not ctx.channel.id == constants.Channels.bot_commands: - raise InWhitelistedContextCheckFailure(constants.Channels.bot_commands) + raise InWhitelistCheckFailure(constants.Channels.bot_commands) embed = await self.create_user_embed(ctx, user) @@ -331,11 +331,7 @@ class Information(Cog): @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) @group(invoke_without_command=True) - @in_whitelisted_context( - whitelisted_channels=(constants.Channels.bot_commands,), - whitelisted_roles=constants.STAFF_ROLES, - redirect_channel=constants.Channels.bot_commands, - ) + @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_ROLES) async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None: """Shows information about the raw API response.""" # I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 4999074b6..8d4688114 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -13,7 +13,7 @@ from discord.ext.commands import Cog, Context, command, guild_only from bot.bot import Bot from bot.constants import Categories, Channels, Roles, URLs -from bot.decorators import in_whitelisted_context +from bot.decorators import in_whitelist from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) @@ -269,12 +269,7 @@ class Snekbox(Cog): @command(name="eval", aliases=("e",)) @guild_only() - @in_whitelisted_context( - whitelisted_channels=EVAL_CHANNELS, - whitelisted_categories=EVAL_CATEGORIES, - whitelisted_roles=EVAL_ROLES, - redirect_channel=Channels.bot_commands, - ) + @in_whitelist(channels=EVAL_CHANNELS, categories=EVAL_CATEGORIES, roles=EVAL_ROLES) async def eval_command(self, ctx: Context, *, code: str = None) -> None: """ Run Python code and get the results. diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 234ec514d..8023eb962 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -13,7 +13,7 @@ from discord.ext.commands import BadArgument, Cog, Context, command from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES, Mention, STAFF_ROLES -from bot.decorators import in_whitelisted_context, with_role +from bot.decorators import in_whitelist, with_role from bot.utils.time import humanize_delta log = logging.getLogger(__name__) @@ -118,11 +118,7 @@ class Utils(Cog): await ctx.message.channel.send(embed=pep_embed) @command() - @in_whitelisted_context( - whitelisted_channels=(Channels.bot_commands,), - whitelisted_roles=STAFF_ROLES, - redirect_channel=Channels.bot_commands, - ) + @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) async def charinfo(self, ctx: Context, *, characters: str) -> None: """Shows you information on up to 25 unicode characters.""" match = re.match(r"<(a?):(\w+):(\d+)>", characters) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 040f52fbf..388b7a338 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -9,7 +9,7 @@ from discord.ext.commands import Cog, Context, command from bot import constants from bot.bot import Bot from bot.cogs.moderation import ModLog -from bot.decorators import InWhitelistedContextCheckFailure, in_whitelisted_context, without_role +from bot.decorators import InWhitelistCheckFailure, in_whitelist, without_role from bot.utils.checks import without_role_check log = logging.getLogger(__name__) @@ -122,7 +122,7 @@ class Verification(Cog): @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True) @without_role(constants.Roles.verified) - @in_whitelisted_context(whitelisted_channels=(constants.Channels.verification,)) + @in_whitelist(channels=(constants.Channels.verification,)) async def accept_command(self, ctx: Context, *_) -> None: # We don't actually care about the args """Accept our rules and gain access to the rest of the server.""" log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.") @@ -138,10 +138,7 @@ class Verification(Cog): await ctx.message.delete() @command(name='subscribe') - @in_whitelisted_context( - whitelisted_channels=(constants.Channels.bot_commands,), - redirect_channel=constants.Channels.bot_commands, - ) + @in_whitelist(channels=(constants.Channels.bot_commands,)) async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args """Subscribe to announcement notifications by assigning yourself the role.""" has_role = False @@ -165,10 +162,7 @@ class Verification(Cog): ) @command(name='unsubscribe') - @in_whitelisted_context( - whitelisted_channels=(constants.Channels.bot_commands,), - redirect_channel=constants.Channels.bot_commands, - ) + @in_whitelist(channels=(constants.Channels.bot_commands,)) async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args """Unsubscribe from announcement notifications by removing the role from yourself.""" has_role = False @@ -193,8 +187,8 @@ class Verification(Cog): # This cannot be static (must have a __func__ attribute). async def cog_command_error(self, ctx: Context, error: Exception) -> None: - """Check for & ignore any InWhitelistedContextCheckFailure.""" - if isinstance(error, InWhitelistedContextCheckFailure): + """Check for & ignore any InWhitelistCheckFailure.""" + if isinstance(error, InWhitelistCheckFailure): error.handled = True @staticmethod diff --git a/bot/decorators.py b/bot/decorators.py index 149564d18..2ee5879f2 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -11,30 +11,34 @@ from discord.errors import NotFound from discord.ext import commands from discord.ext.commands import CheckFailure, Cog, Context -from bot.constants import ERROR_REPLIES, RedirectOutput +from bot.constants import Channels, ERROR_REPLIES, RedirectOutput from bot.utils.checks import with_role_check, without_role_check log = logging.getLogger(__name__) -class InWhitelistedContextCheckFailure(CheckFailure): +class InWhitelistCheckFailure(CheckFailure): """Raised when the `in_whitelist` check fails.""" - def __init__(self, redirect_channel: Optional[int] = None): - error_message = "Sorry, but you are not allowed to use that command here." + def __init__(self, redirect_channel: Optional[int]) -> None: + self.redirect_channel = redirect_channel if redirect_channel: - error_message += f" Please use the <#{redirect_channel}> channel instead." + redirect_message = f" here. Please use the <#{redirect_channel}> channel instead" + else: + redirect_message = "" + + error_message = f"You are not allowed to use that command{redirect_message}." super().__init__(error_message) -def in_whitelisted_context( +def in_whitelist( *, - whitelisted_channels: Container[int] = (), - whitelisted_categories: Container[int] = (), - whitelisted_roles: Container[int] = (), - redirect_channel: Optional[int] = None, + channels: Container[int] = (), + categories: Container[int] = (), + roles: Container[int] = (), + redirect: Optional[int] = Channels.bot_commands, ) -> Callable: """ @@ -46,45 +50,40 @@ def in_whitelisted_context( - `categories`: a container with category ids for whitelisted categories - `roles`: a container with with role ids for whitelisted roles - An optional `redirect_channel` can be provided to redirect users that are not - authorized to use the command in the current context. If no such channel is - provided, the users are simply told that they are not authorized to use the - command. + If the command was invoked in a context that was not whitelisted, the member is either + redirected to the `redirect` channel that was passed (default: #bot-commands) or simply + told that they're not allowed to use this particular command (if `None` was passed). """ - if redirect_channel and redirect_channel not in whitelisted_channels: + if redirect and redirect not in channels: # It does not make sense for the channel whitelist to not contain the redirection - # channel (if provided). That's why we add the redirection channel to the `channels` + # channel (if applicable). That's why we add the redirection channel to the `channels` # container if it's not already in it. As we allow any container type to be passed, # we first create a tuple in order to safely add the redirection channel. # # Note: It's possible for the redirect channel to be in a whitelisted category, but # there's no easy way to check that and as a channel can easily be moved in and out of # categories, it's probably not wise to rely on its category in any case. - whitelisted_channels = tuple(whitelisted_channels) + (redirect_channel,) + channels = tuple(channels) + (redirect,) def predicate(ctx: Context) -> bool: """Check if a command was issued in a whitelisted context.""" - if whitelisted_channels and ctx.channel.id in whitelisted_channels: + if channels and ctx.channel.id in channels: log.trace(f"{ctx.author} may use the `{ctx.command.name}` command as they are in a whitelisted channel.") return True # Only check the category id if we have a category whitelist and the channel has a `category_id` - if ( - whitelisted_categories - and hasattr(ctx.channel, "category_id") - and ctx.channel.category_id in whitelisted_categories - ): + if categories and hasattr(ctx.channel, "category_id") and ctx.channel.category_id in categories: log.trace(f"{ctx.author} may use the `{ctx.command.name}` command as they are in a whitelisted category.") return True # Only check the roles whitelist if we have one and ensure the author's roles attribute returns # an iterable to prevent breakage in DM channels (for if we ever decide to enable commands there). - if whitelisted_roles and any(r.id in whitelisted_roles for r in getattr(ctx.author, "roles", ())): + if roles and any(r.id in roles for r in getattr(ctx.author, "roles", ())): log.trace(f"{ctx.author} may use the `{ctx.command.name}` command as they have a whitelisted role.") return True log.trace(f"{ctx.author} may not use the `{ctx.command.name}` command within this context.") - raise InWhitelistedContextCheckFailure(redirect_channel) + raise InWhitelistCheckFailure(redirect) return commands.check(predicate) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 4a36fe030..6dace1080 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -7,7 +7,7 @@ import discord from bot import constants from bot.cogs import information -from bot.decorators import InWhitelistedContextCheckFailure +from bot.decorators import InWhitelistCheckFailure from tests import helpers @@ -525,7 +525,7 @@ class UserCommandTests(unittest.TestCase): ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=100)) msg = "Sorry, but you may only use this command within <#50>." - with self.assertRaises(InWhitelistedContextCheckFailure, msg=msg): + with self.assertRaises(InWhitelistCheckFailure, msg=msg): asyncio.run(self.cog.user_info.callback(self.cog, ctx)) @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) diff --git a/tests/bot/test_decorators.py b/tests/bot/test_decorators.py index fae7c0c52..645051fec 100644 --- a/tests/bot/test_decorators.py +++ b/tests/bot/test_decorators.py @@ -2,15 +2,15 @@ import collections import unittest import unittest.mock -from bot.decorators import InWhitelistedContextCheckFailure, in_whitelisted_context +from bot.decorators import InWhitelistCheckFailure, in_whitelist from tests import helpers WhitelistedContextTestCase = collections.namedtuple("WhitelistedContextTestCase", ("kwargs", "ctx")) -class InWhitelistedContextTests(unittest.TestCase): - """Tests for the `in_whitelisted_context` check.""" +class InWhitelistTests(unittest.TestCase): + """Tests for the `in_whitelist` check.""" @classmethod def setUpClass(cls): @@ -23,43 +23,43 @@ class InWhitelistedContextTests(unittest.TestCase): cls.staff_role = helpers.MockRole(id=121212) cls.staff_member = helpers.MockMember(roles=(cls.staff_role,)) - cls.whitelisted_channels = (cls.bot_commands.id,) - cls.whitelisted_categories = (cls.help_channel.category_id,) - cls.whitelisted_roles = (cls.staff_role.id,) + cls.channels = (cls.bot_commands.id,) + cls.categories = (cls.help_channel.category_id,) + cls.roles = (cls.staff_role.id,) def test_predicate_returns_true_for_whitelisted_context(self): """The predicate should return `True` if a whitelisted context was passed to it.""" test_cases = ( # Commands issued in whitelisted channels by members without whitelisted roles WhitelistedContextTestCase( - kwargs={"whitelisted_channels": self.whitelisted_channels}, + kwargs={"channels": self.channels}, ctx=helpers.MockContext(channel=self.bot_commands, author=self.non_staff_member) ), - # `redirect_channel` should be added implicitly to the `whitelisted_channels` + # `redirect` should be added implicitly to the `channels` WhitelistedContextTestCase( - kwargs={"redirect_channel": self.bot_commands.id}, + kwargs={"redirect": self.bot_commands.id}, ctx=helpers.MockContext(channel=self.bot_commands, author=self.non_staff_member) ), # Commands issued in a whitelisted category by members without whitelisted roles WhitelistedContextTestCase( - kwargs={"whitelisted_categories": self.whitelisted_categories}, + kwargs={"categories": self.categories}, ctx=helpers.MockContext(channel=self.help_channel, author=self.non_staff_member) ), # Command issued by a staff member in a non-whitelisted channel/category WhitelistedContextTestCase( - kwargs={"whitelisted_roles": self.whitelisted_roles}, + kwargs={"roles": self.roles}, ctx=helpers.MockContext(channel=self.non_whitelisted_channel, author=self.staff_member) ), # With all kwargs provided WhitelistedContextTestCase( kwargs={ - "whitelisted_channels": self.whitelisted_channels, - "whitelisted_categories": self.whitelisted_categories, - "whitelisted_roles": self.whitelisted_roles, - "redirect_channel": self.bot_commands, + "channels": self.channels, + "categories": self.categories, + "roles": self.roles, + "redirect": self.bot_commands, }, ctx=helpers.MockContext(channel=self.help_channel, author=self.staff_member) ), @@ -69,31 +69,31 @@ class InWhitelistedContextTests(unittest.TestCase): # patch `commands.check` with a no-op lambda that just returns the predicate passed to it # so we can test the predicate that was generated from the specified kwargs. with unittest.mock.patch("bot.decorators.commands.check", new=lambda predicate: predicate): - predicate = in_whitelisted_context(**test_case.kwargs) + predicate = in_whitelist(**test_case.kwargs) with self.subTest(test_case=test_case): self.assertTrue(predicate(test_case.ctx)) def test_predicate_raises_exception_for_non_whitelisted_context(self): - """The predicate should raise `InWhitelistedContextCheckFailure` for a non-whitelisted context.""" + """The predicate should raise `InWhitelistCheckFailure` for a non-whitelisted context.""" test_cases = ( - # Failing check with `redirect_channel` + # Failing check with `redirect` WhitelistedContextTestCase( kwargs={ - "whitelisted_categories": self.whitelisted_categories, - "whitelisted_channels": self.whitelisted_channels, - "whitelisted_roles": self.whitelisted_roles, - "redirect_channel": self.bot_commands.id, + "categories": self.categories, + "channels": self.channels, + "roles": self.roles, + "redirect": self.bot_commands.id, }, ctx=helpers.MockContext(channel=self.non_whitelisted_channel, author=self.non_staff_member) ), - # Failing check without `redirect_channel` + # Failing check without `redirect` WhitelistedContextTestCase( kwargs={ - "whitelisted_categories": self.whitelisted_categories, - "whitelisted_channels": self.whitelisted_channels, - "whitelisted_roles": self.whitelisted_roles, + "categories": self.categories, + "channels": self.channels, + "roles": self.roles, }, ctx=helpers.MockContext(channel=self.non_whitelisted_channel, author=self.non_staff_member) ), @@ -102,14 +102,14 @@ class InWhitelistedContextTests(unittest.TestCase): for test_case in test_cases: # Create expected exception message based on whether or not a redirect channel was provided expected_message = "Sorry, but you are not allowed to use that command here." - if test_case.kwargs.get("redirect_channel"): - expected_message += f" Please use the <#{test_case.kwargs['redirect_channel']}> channel instead." + if test_case.kwargs.get("redirect"): + expected_message += f" Please use the <#{test_case.kwargs['redirect']}> channel instead." # patch `commands.check` with a no-op lambda that just returns the predicate passed to it # so we can test the predicate that was generated from the specified kwargs. with unittest.mock.patch("bot.decorators.commands.check", new=lambda predicate: predicate): - predicate = in_whitelisted_context(**test_case.kwargs) + predicate = in_whitelist(**test_case.kwargs) with self.subTest(test_case=test_case): - with self.assertRaises(InWhitelistedContextCheckFailure, msg=expected_message): + with self.assertRaises(InWhitelistCheckFailure, msg=expected_message): predicate(test_case.ctx) -- cgit v1.2.3 From 5e477bab4572a7d07780d3e0d2cd5fa3ceb4a3b8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 24 Apr 2020 11:13:29 -0700 Subject: Fix awaiting non-coroutine when closing the statsd transport `BaseTransport.close()` is not a coroutine and therefore should not be awaited. --- bot/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/bot.py b/bot/bot.py index 6dd5ba896..027d8d2a3 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -75,7 +75,7 @@ class Bot(commands.Bot): await self._resolver.close() if self.stats._transport: - await self.stats._transport.close() + self.stats._transport.close() async def login(self, *args, **kwargs) -> None: """Re-create the connector and set up sessions before logging into Discord.""" -- 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 f5bb251bbfd92bfe67ee9638f2bf6d054eb30502 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 27 Apr 2020 16:01:45 +0200 Subject: Exclude never-run lines from coverage --- tests/bot/cogs/test_cogs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index 39f6492cb..fdda59a8f 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -31,7 +31,7 @@ class CommandNameTests(unittest.TestCase): def walk_modules() -> t.Iterator[ModuleType]: """Yield imported modules from the bot.cogs subpackage.""" def on_error(name: str) -> t.NoReturn: - raise ImportError(name=name) + raise ImportError(name=name) # pragma: no cover # The mock prevents asyncio.get_event_loop() from being called. with mock.patch("discord.ext.tasks.loop"): @@ -71,7 +71,7 @@ class CommandNameTests(unittest.TestCase): for name in self.get_qualified_names(cmd): with self.subTest(cmd=func_name, name=name): - if name in all_names: + if name in all_names: # pragma: no cover conflicts = ", ".join(all_names.get(name, "")) self.fail( f"Name '{name}' of the command {func_name} conflicts with {conflicts}." -- cgit v1.2.3 From 167f57b9cc78708b7c6b48f64442d7bddce2f75c Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 27 Apr 2020 16:02:15 +0200 Subject: Add mock for discord.DMChannels --- tests/helpers.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/helpers.py b/tests/helpers.py index 9001deedf..2b79a6c2a 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -323,6 +323,27 @@ class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): self.mention = f"#{self.name}" +# Create data for the DMChannel instance +state = unittest.mock.MagicMock() +me = unittest.mock.MagicMock() +dm_channel_data = {"id": 1, "recipients": [unittest.mock.MagicMock()]} +dm_channel_instance = discord.DMChannel(me=me, state=state, data=dm_channel_data) + + +class MockDMChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): + """ + A MagicMock subclass to mock TextChannel objects. + + Instances of this class will follow the specifications of `discord.TextChannel` instances. For + more information, see the `MockGuild` docstring. + """ + spec_set = dm_channel_instance + + def __init__(self, **kwargs) -> None: + default_kwargs = {'id': next(self.discord_id), 'recipient': MockUser(), "me": MockUser()} + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) + + # Create a Message instance to get a realistic MagicMock of `discord.Message` message_data = { 'id': 1, -- cgit v1.2.3 From d21e5962be961a267cef6ffef4f7d4aaf1114a08 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 27 Apr 2020 16:03:12 +0200 Subject: Add DMChannel tests for in_whitelist decorator The `in_whitelist` decorator should not fail when a decorated command was called in a DMChannel; it should simply conclude that the user is not allowed to use the command. I've added a test case that uses a DMChannel context with User, not Member, objects. In addition, I've opted to display a test case description in the `subTest`: Simply printing the actual arguments and context is messy and does not actually show you the information you'd like. This description is enough to figure out which test is failing and what the gist of the test is. --- tests/bot/test_decorators.py | 94 +++++++++++++++++++++++++++++--------------- 1 file changed, 63 insertions(+), 31 deletions(-) diff --git a/tests/bot/test_decorators.py b/tests/bot/test_decorators.py index 645051fec..a17dd3e16 100644 --- a/tests/bot/test_decorators.py +++ b/tests/bot/test_decorators.py @@ -2,11 +2,12 @@ import collections import unittest import unittest.mock +from bot import constants from bot.decorators import InWhitelistCheckFailure, in_whitelist from tests import helpers -WhitelistedContextTestCase = collections.namedtuple("WhitelistedContextTestCase", ("kwargs", "ctx")) +InWhitelistTestCase = collections.namedtuple("WhitelistedContextTestCase", ("kwargs", "ctx", "description")) class InWhitelistTests(unittest.TestCase): @@ -18,6 +19,7 @@ class InWhitelistTests(unittest.TestCase): cls.bot_commands = helpers.MockTextChannel(id=123456789, category_id=123456) cls.help_channel = helpers.MockTextChannel(id=987654321, category_id=987654) cls.non_whitelisted_channel = helpers.MockTextChannel(id=666666) + cls.dm_channel = helpers.MockDMChannel() cls.non_staff_member = helpers.MockMember() cls.staff_role = helpers.MockRole(id=121212) @@ -30,38 +32,35 @@ class InWhitelistTests(unittest.TestCase): def test_predicate_returns_true_for_whitelisted_context(self): """The predicate should return `True` if a whitelisted context was passed to it.""" test_cases = ( - # Commands issued in whitelisted channels by members without whitelisted roles - WhitelistedContextTestCase( + InWhitelistTestCase( kwargs={"channels": self.channels}, - ctx=helpers.MockContext(channel=self.bot_commands, author=self.non_staff_member) + ctx=helpers.MockContext(channel=self.bot_commands, author=self.non_staff_member), + description="In whitelisted channels by members without whitelisted roles", ), - # `redirect` should be added implicitly to the `channels` - WhitelistedContextTestCase( + InWhitelistTestCase( kwargs={"redirect": self.bot_commands.id}, - ctx=helpers.MockContext(channel=self.bot_commands, author=self.non_staff_member) + ctx=helpers.MockContext(channel=self.bot_commands, author=self.non_staff_member), + description="`redirect` should be implicitly added to `channels`", ), - - # Commands issued in a whitelisted category by members without whitelisted roles - WhitelistedContextTestCase( + InWhitelistTestCase( kwargs={"categories": self.categories}, - ctx=helpers.MockContext(channel=self.help_channel, author=self.non_staff_member) + ctx=helpers.MockContext(channel=self.help_channel, author=self.non_staff_member), + description="Whitelisted category without whitelisted role", ), - - # Command issued by a staff member in a non-whitelisted channel/category - WhitelistedContextTestCase( + InWhitelistTestCase( kwargs={"roles": self.roles}, - ctx=helpers.MockContext(channel=self.non_whitelisted_channel, author=self.staff_member) + ctx=helpers.MockContext(channel=self.non_whitelisted_channel, author=self.staff_member), + description="Whitelisted role outside of whitelisted channel/category" ), - - # With all kwargs provided - WhitelistedContextTestCase( + InWhitelistTestCase( kwargs={ "channels": self.channels, "categories": self.categories, "roles": self.roles, "redirect": self.bot_commands, }, - ctx=helpers.MockContext(channel=self.help_channel, author=self.staff_member) + ctx=helpers.MockContext(channel=self.help_channel, author=self.staff_member), + description="Case with all whitelist kwargs used", ), ) @@ -71,45 +70,78 @@ class InWhitelistTests(unittest.TestCase): with unittest.mock.patch("bot.decorators.commands.check", new=lambda predicate: predicate): predicate = in_whitelist(**test_case.kwargs) - with self.subTest(test_case=test_case): + with self.subTest(test_description=test_case.description): self.assertTrue(predicate(test_case.ctx)) def test_predicate_raises_exception_for_non_whitelisted_context(self): """The predicate should raise `InWhitelistCheckFailure` for a non-whitelisted context.""" test_cases = ( - # Failing check with `redirect` - WhitelistedContextTestCase( + # Failing check with explicit `redirect` + InWhitelistTestCase( kwargs={ "categories": self.categories, "channels": self.channels, "roles": self.roles, "redirect": self.bot_commands.id, }, - ctx=helpers.MockContext(channel=self.non_whitelisted_channel, author=self.non_staff_member) + ctx=helpers.MockContext(channel=self.non_whitelisted_channel, author=self.non_staff_member), + description="Failing check with an explicit redirect channel", + ), + + # Failing check with implicit `redirect` + InWhitelistTestCase( + kwargs={ + "categories": self.categories, + "channels": self.channels, + "roles": self.roles, + }, + ctx=helpers.MockContext(channel=self.non_whitelisted_channel, author=self.non_staff_member), + description="Failing check with an implicit redirect channel", ), # Failing check without `redirect` - WhitelistedContextTestCase( + InWhitelistTestCase( + kwargs={ + "categories": self.categories, + "channels": self.channels, + "roles": self.roles, + "redirect": None, + }, + ctx=helpers.MockContext(channel=self.non_whitelisted_channel, author=self.non_staff_member), + description="Failing check without a redirect channel", + ), + + # Command issued in DM channel + InWhitelistTestCase( kwargs={ "categories": self.categories, "channels": self.channels, "roles": self.roles, + "redirect": None, }, - ctx=helpers.MockContext(channel=self.non_whitelisted_channel, author=self.non_staff_member) + ctx=helpers.MockContext(channel=self.dm_channel, author=self.dm_channel.me), + description="Commands issued in DM channel should be rejected", ), ) for test_case in test_cases: - # Create expected exception message based on whether or not a redirect channel was provided - expected_message = "Sorry, but you are not allowed to use that command here." - if test_case.kwargs.get("redirect"): - expected_message += f" Please use the <#{test_case.kwargs['redirect']}> channel instead." + if "redirect" not in test_case.kwargs or test_case.kwargs["redirect"] is not None: + # There are two cases in which we have a redirect channel: + # 1. No redirect channel was passed; the default value of `bot_commands` is used + # 2. An explicit `redirect` is set that is "not None" + redirect_channel = test_case.kwargs.get("redirect", constants.Channels.bot_commands) + redirect_message = f" here. Please use the <#{redirect_channel}> channel instead" + else: + # If an explicit `None` was passed for `redirect`, there is no redirect channel + redirect_message = "" + + exception_message = f"You are not allowed to use that command{redirect_message}." # patch `commands.check` with a no-op lambda that just returns the predicate passed to it # so we can test the predicate that was generated from the specified kwargs. with unittest.mock.patch("bot.decorators.commands.check", new=lambda predicate: predicate): predicate = in_whitelist(**test_case.kwargs) - with self.subTest(test_case=test_case): - with self.assertRaises(InWhitelistCheckFailure, msg=expected_message): + with self.subTest(test_description=test_case.description): + with self.assertRaisesRegex(InWhitelistCheckFailure, exception_message): predicate(test_case.ctx) -- 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 b0c07a9e5212ce38c2237b0b1294c344602d5d6f Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Tue, 28 Apr 2020 00:31:41 +0200 Subject: Insert help channels at the bottom of the category This commit reintroduces bottom sorting for help channels during a category move, but in a more reliable way that also causes far fewer "channel list glitches". This is accomplished by using the "bulk channels update" endpoint of the Discord API. ----------- The Problem ----------- Discord's positioning system is not that easy to work with for developers: Instead of having separate pools of position integers for each category, all text channels are considered to be part of the same "position pool" (or "bucket" in discord.py terms). This also means that changing the position integer of one channel may cause the position integer of another to change, regardless of if the channels share a category or even of if they are close to each other in the guild. As clients receive the position update for each channel as separate CHANNEL UPDATE events, this means that moving one channel may cause other channels to (temporarily) jump around as the client receives the EVENTS from the API. As some position changes affect all the channels in the guild, this will also trigger a nice "channel wave" rolling down the channel list from time to time. For our use case, this was exacerbated by the way `discord.py` handles position changes: It will enumerate the entire, sorted channel list whenever a position change occurs and send a "bulk request" with updated position integers for the entire guild to Discord. This was the reason that all of the sorting methods we've tried resulted in a lot of those "wave" glitches as clients would get a lot of CHANNEL UPDATE events. In addition, the way `discord.py` inserted channels into the payload also meant that our "high integer" methods did not work reliably. ------------ The Solution ------------ Fortunately, there is a solution that will work well most of the time: Making a `bulk channels update` request with only channels of the category we're currently interested in. By providing the current position of the channels that are already in the category, combined with the correct position of the channel moving into the category, we effectively "lock in" the existing channels at the location they already have. The new channel is simply moved into the right position in relation to the existing channels. This means that effectively, we only communicate one channel position change to Discord, making sure that as few channels as possible actually change their formal "position int". From there on, there are two options: 1. Keep the existing channels in place, add the new channel at the bottom (new highest int) 2. Keep the existing channels in place, add the new channel at the top (new lowest int) Both methods work, but option two has a flaw: The position int will get smaller and smaller, until it reaches `0`. Since negative position integers are not allowed, the entire category now has to be shifted upwards to make room for new top channels. This comes at the cost of a "wave" glitch within the category. My initial instinct was to solve this by giving the channels in the category a "really high" straight of position ints, but as Discord recalculates the ints from time to time anyway, this does not work. That's why I opted for the `bottom sort` option, which does not suffer from that issue. I've also asked the question of `top` vs `bottom` in #admins, without the context above, and the preferred method seemed to be `bottom` in any case. ----------- Limitations ----------- While Discord doesn't care that much about duplicates or neatly ascending integers, some channel move actions will inevitably result in a recalculation of the positions ints. This means that "wave" glitches may still happen from time to time, but they should be infrequent. (They also happen if you drag channels in your client; it seems to be a fundamental part of how positioning works.) I think this is something we'll have to live with. Another thing that I suspect may happen is that during times of API lag in the middle of help channel rush hour, some CHANNEL UPDATE events belonging to previous channel moves will not be received/processed yet by the time we make the next move. As we rely on cached position integers, this could mean that from time to time a channel is inserted near the bottom but not at the bottom. As Discord sends these CHANNEL UPDATE replies as individual events in an asynchronous manner instead of as a single response to our `bulk channels update` request, there's nothing much we can do about this. However, I have yet to observe this, so maybe it will never happen. --- bot/cogs/help_channels.py | 57 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index ef58ca9a1..3dea3b013 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -478,6 +478,45 @@ class HelpChannels(Scheduler, commands.Cog): self.schedule_task(channel.id, data) + async def move_to_bottom_position(self, channel: discord.TextChannel, category_id: int, **options) -> None: + """ + Move the `channel` to the bottom position of `category` and edit channel attributes. + + To ensure "stable sorting", we use the `bulk_channel_update` endpoint and provide the current + positions of the other channels in the category as-is. This should make sure that the channel + really ends up at the bottom of the category. + + If `options` are provided, the channel will be edited after the move is completed. This is the + same order of operations that `discord.TextChannel.edit` uses. For information on available + options, see the documention on `discord.TextChannel.edit`. While possible, position-related + options should be avoided, as it may interfere with the category move we perform. + """ + # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. + category = await self.try_get_channel(category_id) + + payload = [{"id": c.id, "position": c.position} for c in category.channels] + + # Calculate the bottom position based on the current highest position in the category. If the + # category is currently empty, we simply use the current position of the channel to avoid making + # unnecessary changes to positions in the guild. + bottom_position = payload[-1]["position"] + 1 if payload else channel.position + + payload.append( + { + "id": channel.id, + "position": bottom_position, + "parent_id": category.id, + "lock_permissions": True, + } + ) + + # We use d.py's method to ensure our request is processed by d.py's rate limit manager + await self.bot.http.bulk_channel_update(category.guild.id, payload) + + # Now that the channel is moved, we can edit the other attributes + if options: + await channel.edit(**options) + async def move_to_available(self) -> None: """Make a channel available.""" log.trace("Making a channel available.") @@ -489,10 +528,10 @@ class HelpChannels(Scheduler, commands.Cog): log.trace(f"Moving #{channel} ({channel.id}) to the Available category.") - await channel.edit( + await self.move_to_bottom_position( + channel=channel, + category_id=constants.Categories.help_available, name=f"{AVAILABLE_EMOJI}{NAME_SEPARATOR}{self.get_clean_channel_name(channel)}", - category=self.available_category, - sync_permissions=True, topic=AVAILABLE_TOPIC, ) @@ -506,10 +545,10 @@ class HelpChannels(Scheduler, commands.Cog): """ log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") - await channel.edit( + await self.move_to_bottom_position( + channel=channel, + category_id=constants.Categories.help_dormant, name=self.get_clean_channel_name(channel), - category=self.dormant_category, - sync_permissions=True, topic=DORMANT_TOPIC, ) @@ -540,10 +579,10 @@ class HelpChannels(Scheduler, commands.Cog): """Make a channel in-use and schedule it to be made dormant.""" log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") - await channel.edit( + await self.move_to_bottom_position( + channel=channel, + category_id=constants.Categories.help_in_use, name=f"{IN_USE_UNANSWERED_EMOJI}{NAME_SEPARATOR}{self.get_clean_channel_name(channel)}", - category=self.in_use_category, - sync_permissions=True, topic=IN_USE_TOPIC, ) -- cgit v1.2.3 From 634dbc93645aebf87d102b1321001f2021def979 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Tue, 28 Apr 2020 01:29:09 +0200 Subject: Add option to ingore channels in help categories As we want to add an "informational" channel to the `Python Help: Available` category, we need to make sure that the Help Channel System ignores that channel. To do that, I've added an `is_excluded_channel` staticmethod that returns `True` if a channel is not a TextChannel or if it's in a special EXCLUDED_CHANNELS constant. This method is then used in the method that yields help channels from a category and in the `on_message` event listener that determines if a channel should be moved from `Available` to `Occupied`. --- bot/cogs/help_channels.py | 13 ++++++++++--- bot/constants.py | 1 + config-default.yml | 3 +++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 3dea3b013..7aeaa2194 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -10,6 +10,7 @@ from datetime import datetime from pathlib import Path import discord +import discord.abc from discord.ext import commands from bot import constants @@ -21,6 +22,7 @@ log = logging.getLogger(__name__) ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" MAX_CHANNELS_PER_CATEGORY = 50 +EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help,) AVAILABLE_TOPIC = """ This channel is available. Feel free to ask a question in order to claim this channel! @@ -283,13 +285,18 @@ class HelpChannels(Scheduler, commands.Cog): return name + @staticmethod + def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool: + """Check if a channel should be excluded from the help channel system.""" + return not isinstance(channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS + def get_category_channels(self, category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: """Yield the text channels of the `category` in an unsorted manner.""" log.trace(f"Getting text channels in the category '{category}' ({category.id}).") # This is faster than using category.channels because the latter sorts them. for channel in self.bot.get_guild(constants.Guild.id).channels: - if channel.category_id == category.id and isinstance(channel, discord.TextChannel): + if channel.category_id == category.id and not self.is_excluded_channel(channel): yield channel @staticmethod @@ -670,8 +677,8 @@ class HelpChannels(Scheduler, commands.Cog): await self.check_for_answer(message) - if not self.is_in_category(channel, constants.Categories.help_available): - return # Ignore messages outside the Available category. + if not self.is_in_category(channel, constants.Categories.help_available) or self.is_excluded_channel(channel): + return # Ignore messages outside the Available category or in excluded channels. log.trace("Waiting for the cog to be ready before processing messages.") await self.ready.wait() diff --git a/bot/constants.py b/bot/constants.py index 49098c9f2..a00b59505 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -383,6 +383,7 @@ class Channels(metaclass=YAMLGetter): dev_log: int esoteric: int helpers: int + how_to_get_help: int message_log: int meta: int mod_alerts: int diff --git a/config-default.yml b/config-default.yml index b0165adf6..78a2ff853 100644 --- a/config-default.yml +++ b/config-default.yml @@ -132,6 +132,9 @@ guild: meta: 429409067623251969 python_discussion: 267624335836053506 + # Python Help: Available + how_to_get_help: 704250143020417084 + # Logs attachment_log: &ATTACH_LOG 649243850006855680 message_log: &MESSAGE_LOG 467752170159079424 -- cgit v1.2.3 From 288ec414f6cc67068a2ed91887bd29d24a82cdcd Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Tue, 28 Apr 2020 01:37:59 +0200 Subject: Log ID of member who claimed a help channel --- bot/cogs/help_channels.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 7aeaa2194..b5cb37015 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -694,6 +694,7 @@ class HelpChannels(Scheduler, commands.Cog): ) return + log.info(f"Channel #{channel} was claimed by `{message.author.id}`.") await self.move_to_in_use(channel) await self.revoke_send_permissions(message.author) # Add user with channel for dormant check. -- 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 2c48aa978ece0b26c158faa6080fc16649943eed Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 29 Apr 2020 16:51:03 -0700 Subject: Log unhandled errors from event listeners By default, discord.py prints them to stderr. To better help detect such errors in production, they should instead be logged with an appropriate log level. Some sentry metadata has also been included. `on_error` doesn't work as a listener in a cog so it's been put in the Bot subclass. Fixes #911 --- bot/bot.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bot/bot.py b/bot/bot.py index 6dd5ba896..49fac27e8 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -7,6 +7,7 @@ from typing import Optional import aiohttp import discord from discord.ext import commands +from sentry_sdk import push_scope from bot import DEBUG_MODE, api, constants from bot.async_stats import AsyncStatsClient @@ -155,3 +156,14 @@ class Bot(commands.Bot): gateway event before giving up and thus not populating the cache for unavailable guilds. """ await self._guild_available.wait() + + async def on_error(self, event: str, *args, **kwargs) -> None: + """Log errors raised in event listeners rather than printing them to stderr.""" + self.stats.incr(f"errors.event.{event}") + + with push_scope() as scope: + scope.set_tag("event", event) + scope.set_extra("args", args) + scope.set_extra("kwargs", kwargs) + + log.exception(f"Unhandled exception in {event}.") -- cgit v1.2.3 From cfc5720925b6bbc40c45507f8579145a0014a6eb Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Thu, 30 Apr 2020 02:05:29 +0100 Subject: Run a category check before logging that we are checking for an answered help channel --- bot/cogs/help_channels.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index b5cb37015..b714a1642 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -649,10 +649,11 @@ class HelpChannels(Scheduler, commands.Cog): async def check_for_answer(self, message: discord.Message) -> None: """Checks for whether new content in a help channel comes from non-claimants.""" channel = message.channel - log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") # Confirm the channel is an in use help channel if self.is_in_category(channel, constants.Categories.help_in_use): + log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") + # Check if there is an entry in unanswered (does not persist across restarts) if channel.id in self.unanswered: claimant_id = self.help_channel_claimants[channel].id -- cgit v1.2.3 From ba442e1d2f1165e9a8d9d4f8363df9153a6bdd61 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 30 Apr 2020 18:30:09 -0700 Subject: Display animated avatars in the user info command Fixes #914 --- bot/cogs/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 4eb36c340..ef2f308ca 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -206,7 +206,7 @@ class Information(Cog): description="\n\n".join(description) ) - embed.set_thumbnail(url=user.avatar_url_as(format="png")) + embed.set_thumbnail(url=user.avatar_url_as(static_format="png")) embed.colour = user.top_role.colour if roles else Colour.blurple() return embed -- cgit v1.2.3 From b43379d663a86680f762d20a7bd27a20927d4bfc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 30 Apr 2020 18:35:03 -0700 Subject: Tests: change avatar_url_as assertion to use static_format --- tests/bot/cogs/test_information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 6dace1080..b5f928dd6 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -485,7 +485,7 @@ class UserEmbedTests(unittest.TestCase): user.avatar_url_as.return_value = "avatar url" embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - user.avatar_url_as.assert_called_once_with(format="png") + user.avatar_url_as.assert_called_once_with(static_format="png") self.assertEqual(embed.thumbnail.url, "avatar url") -- cgit v1.2.3 From bc478485248199f93ee8d5a64ddcb7516f1c6ef5 Mon Sep 17 00:00:00 2001 From: Savant-Dev Date: Fri, 1 May 2020 06:17:03 -0400 Subject: Update extension filter to distinguish .txt in cases where messages are longer than 2000 characters --- bot/cogs/antimalware.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 79bf486a4..053f1a01d 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -38,6 +38,18 @@ class AntiMalware(Cog): "It looks like you tried to attach a Python file - " f"please use a code-pasting service such as {URLs.site_schema}{URLs.site_paste}" ) + elif ".txt" in extensions_blocked: + # Work around Discord AutoConversion of messages longer than 2000 chars to .txt + cmd_channel = self.bot.get_channel(Channels.bot_commands) + embed.description = ( + "**Uh-oh!** It looks like your message got zapped by our spam filter. " + "We currently don't allow `.txt` attachments, so here are some tips to help you travel safely: \n\n" + "**1.** You tried to send a message longer than 2000 characters (Discord uploads these as files) \n" + "• Try shortening your message to fit within the character limit or use a pasting service (see below) " + "\n\n**2.** You tried to show someone your code (no worries, we'd love to see it!)\n" + f"• Try using codeblocks (run `!code-blocks` in {cmd_channel.mention}) or use a pasting service \n\n" + f"If you would like, here is a pasting service we like to use: {URLs.site_schema}{URLs.site_paste}" + ) elif extensions_blocked: whitelisted_types = ', '.join(AntiMalwareConfig.whitelist) meta_channel = self.bot.get_channel(Channels.meta) -- cgit v1.2.3 From 0ce4b2fc20f1a5ef671a415d36e78e997796f19e Mon Sep 17 00:00:00 2001 From: Savant-Dev Date: Fri, 1 May 2020 06:19:19 -0400 Subject: Update extension filter to distinguish .txt in cases where messages are longer than 2000 characters --- bot/cogs/antimalware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 053f1a01d..72fb574b9 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -44,7 +44,7 @@ class AntiMalware(Cog): embed.description = ( "**Uh-oh!** It looks like your message got zapped by our spam filter. " "We currently don't allow `.txt` attachments, so here are some tips to help you travel safely: \n\n" - "**1.** You tried to send a message longer than 2000 characters (Discord uploads these as files) \n" + "**1.** You tried to send a message longer than 2000 characters \n" "• Try shortening your message to fit within the character limit or use a pasting service (see below) " "\n\n**2.** You tried to show someone your code (no worries, we'd love to see it!)\n" f"• Try using codeblocks (run `!code-blocks` in {cmd_channel.mention}) or use a pasting service \n\n" -- 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 9c20a63e08feae35c14418820f0a6afc307ea934 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 1 May 2020 16:51:22 -0700 Subject: Remove the mention command It was made obsolete by a new Discord feature. Users can be granted a permission to mention a role despite the role being set as non-mentionable. --- bot/cogs/utils.py | 48 ++---------------------------------------------- 1 file changed, 2 insertions(+), 46 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 8023eb962..89d556f58 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -2,19 +2,16 @@ 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, Union -from dateutil import relativedelta -from discord import Colour, Embed, Message, Role +from discord import Colour, Embed from discord.ext.commands import BadArgument, Cog, Context, command from bot.bot import Bot -from bot.constants import Channels, MODERATION_ROLES, Mention, STAFF_ROLES +from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES from bot.decorators import in_whitelist, with_role -from bot.utils.time import humanize_delta log = logging.getLogger(__name__) @@ -161,47 +158,6 @@ class Utils(Cog): await ctx.send(embed=embed) - @command() - @with_role(*MODERATION_ROLES) - async def mention(self, ctx: Context, *, role: Role) -> None: - """Set a role to be mentionable for a limited time.""" - if role.mentionable: - await ctx.send(f"{role} is already mentionable!") - return - - await role.edit(reason=f"Role unlocked by {ctx.author}", mentionable=True) - - human_time = humanize_delta(relativedelta.relativedelta(seconds=Mention.message_timeout)) - await ctx.send( - f"{role} has been made mentionable. I will reset it in {human_time}, or when someone mentions this role." - ) - - def check(m: Message) -> bool: - """Checks that the message contains the role mention.""" - return role in m.role_mentions - - try: - msg = await self.bot.wait_for("message", check=check, timeout=Mention.message_timeout) - except TimeoutError: - await role.edit(mentionable=False, reason="Automatic role lock - timeout.") - await ctx.send(f"{ctx.author.mention}, you took too long. I have reset {role} to be unmentionable.") - return - - if any(r.id in MODERATION_ROLES for r in msg.author.roles): - await sleep(Mention.reset_delay) - await role.edit(mentionable=False, reason=f"Automatic role lock by {msg.author}") - await ctx.send( - f"{ctx.author.mention}, I have reset {role} to be unmentionable as " - f"{msg.author if msg.author != ctx.author else 'you'} sent a message mentioning it." - ) - return - - await role.edit(mentionable=False, reason=f"Automatic role lock - unauthorised use by {msg.author}") - await ctx.send( - f"{ctx.author.mention}, I have reset {role} to be unmentionable " - 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: """ -- 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 debbe647589aa4c8d110cf7b25e4a68fe9eb5ff6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 2 May 2020 09:35:13 -0700 Subject: Remove mention command constants --- bot/constants.py | 7 ------- config-default.yml | 3 --- 2 files changed, 10 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index a00b59505..da29125eb 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -550,13 +550,6 @@ class HelpChannels(metaclass=YAMLGetter): notify_roles: List[int] -class Mention(metaclass=YAMLGetter): - section = 'mention' - - message_timeout: int - reset_delay: int - - class RedirectOutput(metaclass=YAMLGetter): section = 'redirect_output' diff --git a/config-default.yml b/config-default.yml index 78a2ff853..ff6790423 100644 --- a/config-default.yml +++ b/config-default.yml @@ -507,9 +507,6 @@ free: cooldown_rate: 1 cooldown_per: 60.0 -mention: - message_timeout: 300 - reset_delay: 5 help_channels: enable: true -- 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 b6ebfb756a03f337da0a3da37c985b798a316de2 Mon Sep 17 00:00:00 2001 From: Savant-Dev Date: Mon, 4 May 2020 18:49:01 -0400 Subject: Update antimalware to filter txt files in cases where messages were longer than 2000 chars --- bot/cogs/antimalware.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 72fb574b9..66b5073e8 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -44,11 +44,11 @@ class AntiMalware(Cog): embed.description = ( "**Uh-oh!** It looks like your message got zapped by our spam filter. " "We currently don't allow `.txt` attachments, so here are some tips to help you travel safely: \n\n" - "**1.** You tried to send a message longer than 2000 characters \n" - "• Try shortening your message to fit within the character limit or use a pasting service (see below) " - "\n\n**2.** You tried to show someone your code (no worries, we'd love to see it!)\n" - f"• Try using codeblocks (run `!code-blocks` in {cmd_channel.mention}) or use a pasting service \n\n" - f"If you would like, here is a pasting service we like to use: {URLs.site_schema}{URLs.site_paste}" + "• If you attempted to send a message longer than 2000 characters, try shortening your message " + "to fit within the character limit or use a pasting service (see below) \n\n" + "• If you tried to show someone your code, you can use codeblocks \n(run `!code-blocks` in " + f"{cmd_channel.mention} for more information) or use a pasting service like: " + f"\n\n{URLs.site_schema}{URLs.site_paste}" ) elif extensions_blocked: whitelisted_types = ', '.join(AntiMalwareConfig.whitelist) -- 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 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 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