From f0795ea53247501cc38615f57aabe21685de7251 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 5 May 2020 02:19:03 +0200 Subject: Create utility function for uploading to paste service. --- bot/utils/__init__.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 9b32e515d..0f39a1bc8 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,9 +1,42 @@ +import logging from abc import ABCMeta +from typing import Optional +from aiohttp import ClientConnectorError, ClientSession from discord.ext.commands import CogMeta +from bot.constants import URLs + +log = logging.getLogger(__name__) + class CogABCMeta(CogMeta, ABCMeta): """Metaclass for ABCs meant to be implemented as Cogs.""" pass + + +async def send_to_paste_service(http_session: ClientSession, contents: str, *, extension: str = "") -> Optional[str]: + """ + Upload `contents` to the paste service. + + `http_session` should be the current running ClientSession from aiohttp + `extension` is added to the output URL + + When an error occurs, `None` is returned, otherwise the generated URL with the suffix. + """ + extension = extension and f".{extension}" + log.debug(f"Sending contents of size {len(contents.encode())} bytes to paste service.") + paste_url = URLs.paste_service.format(key="documents") + try: + async with http_session.post(paste_url, data=contents) as response: + response_json = await response.json() + except ClientConnectorError: + log.warning(f"Failed to connect to paste service at url {paste_url}.") + return + if "message" in response_json: + log.warning(f"Paste service returned error {response_json['message']} with status code {response.status}.") + return + elif "key" in response_json: + log.trace(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") + return URLs.paste_service.format(key=response_json['key']) + extension -- cgit v1.2.3 From 4980726e3a68bb2bca966c9c3e09568da2162af0 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 5 May 2020 02:20:01 +0200 Subject: Attempt requests multiple times with connection errors. --- bot/utils/__init__.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 0f39a1bc8..6b9c890c8 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -9,6 +9,8 @@ from bot.constants import URLs log = logging.getLogger(__name__) +FAILED_REQUEST_ATTEMPTS = 3 + class CogABCMeta(CogMeta, ABCMeta): """Metaclass for ABCs meant to be implemented as Cogs.""" @@ -28,15 +30,18 @@ async def send_to_paste_service(http_session: ClientSession, contents: str, *, e extension = extension and f".{extension}" log.debug(f"Sending contents of size {len(contents.encode())} bytes to paste service.") paste_url = URLs.paste_service.format(key="documents") - try: - async with http_session.post(paste_url, data=contents) as response: - response_json = await response.json() - except ClientConnectorError: - log.warning(f"Failed to connect to paste service at url {paste_url}.") - return - if "message" in response_json: - log.warning(f"Paste service returned error {response_json['message']} with status code {response.status}.") - return - elif "key" in response_json: - log.trace(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") - return URLs.paste_service.format(key=response_json['key']) + extension + for attempt in range(1, FAILED_REQUEST_ATTEMPTS + 1): + try: + async with http_session.post(paste_url, data=contents) as response: + response_json = await response.json() + except ClientConnectorError: + log.warning( + f"Failed to connect to paste service at url {paste_url}, " + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + ) + if "message" in response_json: + log.warning(f"Paste service returned error {response_json['message']} with status code {response.status}.") + return + elif "key" in response_json: + log.trace(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") + return URLs.paste_service.format(key=response_json['key']) + extension -- cgit v1.2.3 From 2644316b07fdecbe834083c761ab5c7731e60a09 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 6 May 2020 02:20:57 +0200 Subject: Send long eval output to paste service. --- bot/cogs/eval.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 52136fc8d..b739668b0 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -15,6 +15,7 @@ from bot.bot import Bot from bot.constants import Roles from bot.decorators import with_role from bot.interpreter import Interpreter +from bot.utils import send_to_paste_service log = logging.getLogger(__name__) @@ -171,6 +172,15 @@ async def func(): # (None,) -> Any res = traceback.format_exc() out, embed = self._format(code, res) + if len(out) > 1500 or out.count("\n") > 15: + paste_link = await send_to_paste_service(self.bot.http_session, out, extension="py") + await ctx.send( + f"```py\n{out[:1500]}\n```" + f"... response truncated; full contents at {paste_link}", + embed=embed + ) + return + await ctx.send(f"```py\n{out}```", embed=embed) @group(name='internal', aliases=('int',)) -- cgit v1.2.3 From 1b98e6c839a3e841115fb6c150855e673bc1ef5b Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 6 May 2020 02:43:08 +0200 Subject: Increase log level. --- bot/utils/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 6b9c890c8..011e41227 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -43,5 +43,5 @@ async def send_to_paste_service(http_session: ClientSession, contents: str, *, e log.warning(f"Paste service returned error {response_json['message']} with status code {response.status}.") return elif "key" in response_json: - log.trace(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") + log.info(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") return URLs.paste_service.format(key=response_json['key']) + extension -- cgit v1.2.3 From d0d205409ccf00b14f535573b343831f31bd917c Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 6 May 2020 02:43:42 +0200 Subject: Handle failed paste uploads. --- bot/cogs/eval.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index b739668b0..c75c1e55f 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -174,9 +174,14 @@ async def func(): # (None,) -> Any out, embed = self._format(code, res) if len(out) > 1500 or out.count("\n") > 15: paste_link = await send_to_paste_service(self.bot.http_session, out, extension="py") + if paste_link is not None: + paste_text = f"full contents at {paste_link}" + else: + paste_text = "failed to upload contents to paste service." + await ctx.send( f"```py\n{out[:1500]}\n```" - f"... response truncated; full contents at {paste_link}", + f"... response truncated; {paste_text}", embed=embed ) return -- cgit v1.2.3 From 077a1ef1eb4eb07325dde5b6b625a84ccb5669ee Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 6 May 2020 02:47:15 +0200 Subject: Use new util function for uploading output. --- bot/cogs/snekbox.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 8d4688114..2aab8fdb1 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -14,6 +14,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_whitelist +from bot.utils import send_to_paste_service from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) @@ -70,17 +71,7 @@ class Snekbox(Cog): if len(output) > MAX_PASTE_LEN: log.info("Full output is too long to upload") return "too long to upload" - - url = URLs.paste_service.format(key="documents") - try: - async with self.bot.http_session.post(url, data=output, raise_for_status=True) as resp: - data = await resp.json() - - if "key" in data: - return URLs.paste_service.format(key=data["key"]) - except Exception: - # 400 (Bad Request) means there are too many characters - log.exception("Failed to upload full output to paste service!") + return await send_to_paste_service(self.bot.http_session, output, extension="txt") @staticmethod def prepare_input(code: str) -> str: -- cgit v1.2.3 From c94c0eaef4ccb64ee3f664ed65837b1f5afd5c59 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 7 May 2020 01:43:25 +0200 Subject: Continue on failed connections. Not using skipping the iteration but continuing directly caused `response_json` being checked but not defined in case of connection errors. Co-authored-by: MarkKoz --- bot/utils/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 011e41227..41e54c3d5 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -39,6 +39,7 @@ async def send_to_paste_service(http_session: ClientSession, contents: str, *, e f"Failed to connect to paste service at url {paste_url}, " f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." ) + continue if "message" in response_json: log.warning(f"Paste service returned error {response_json['message']} with status code {response.status}.") return -- cgit v1.2.3 From 93a805d950f9daf14ba50131547d888e1f6314b3 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 7 May 2020 01:45:28 +0200 Subject: Handle broad exceptions. In the case an unexpected exception happens, this allows us to try the request again or let the function exit gracefully in the case of multiple fails. --- bot/utils/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 41e54c3d5..b9290e5a6 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -40,6 +40,13 @@ async def send_to_paste_service(http_session: ClientSession, contents: str, *, e f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." ) continue + except Exception: + log.exception( + f"An unexpected error has occurred during handling of the request, " + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + ) + continue + if "message" in response_json: log.warning(f"Paste service returned error {response_json['message']} with status code {response.status}.") return -- cgit v1.2.3 From 8f7551540cc2770b498bfe38a9f72c0950bbd929 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 7 May 2020 01:51:27 +0200 Subject: continue on internal server errors. In the case we receive `"message"` in the json response, the server had an internal error and we can attempt the request again. --- bot/utils/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index b9290e5a6..b273b2cde 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -48,8 +48,11 @@ async def send_to_paste_service(http_session: ClientSession, contents: str, *, e continue if "message" in response_json: - log.warning(f"Paste service returned error {response_json['message']} with status code {response.status}.") - return + log.warning( + f"Paste service returned error {response_json['message']} with status code {response.status}, " + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + ) + continue elif "key" in response_json: log.info(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") return URLs.paste_service.format(key=response_json['key']) + extension -- cgit v1.2.3 From d98a418f9cafc8ce907293cb833cabfd68c92fb3 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 7 May 2020 01:52:44 +0200 Subject: Log unexpected JSON responses. --- bot/utils/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index b273b2cde..ec7cbd214 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -56,3 +56,7 @@ async def send_to_paste_service(http_session: ClientSession, contents: str, *, e elif "key" in response_json: log.info(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") return URLs.paste_service.format(key=response_json['key']) + extension + log.warning( + f"Got unexpected JSON response from paste service: {response_json}\n" + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + ) -- cgit v1.2.3 From 5b11b248b945cd2a732c6d8d430d117fc062cc8d Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 7 May 2020 16:46:32 +0200 Subject: Remove tests from moved function. --- tests/bot/cogs/test_snekbox.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 1dec0ccaf..d32d80ead 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -1,5 +1,4 @@ import asyncio -import logging import unittest from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, patch @@ -53,20 +52,6 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): raise_for_status=True ) - async def test_upload_output_gracefully_fallback_if_exception_during_request(self): - """Output upload gracefully fallback if the upload fail.""" - resp = MagicMock() - resp.json = AsyncMock(side_effect=Exception) - self.bot.http_session.post().__aenter__.return_value = resp - - log = logging.getLogger("bot.cogs.snekbox") - with self.assertLogs(logger=log, level='ERROR'): - await self.cog.upload_output('My awesome output!') - - async def test_upload_output_gracefully_fallback_if_no_key_in_response(self): - """Output upload gracefully fallback if there is no key entry in the response body.""" - self.assertEqual((await self.cog.upload_output('My awesome output!')), None) - def test_prepare_input(self): cases = ( ('print("Hello world!")', 'print("Hello world!")', 'non-formatted'), -- cgit v1.2.3 From 14c670dfa87e142e24c027e2976fa02b07c4d7ac Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 7 May 2020 17:11:56 +0200 Subject: Adjust behaviour for new func usage. --- tests/bot/cogs/test_snekbox.py | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index d32d80ead..f4c13fc43 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -35,21 +35,12 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): result = await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1)) self.assertEqual(result, "too long to upload") - async def test_upload_output(self): + @patch("bot.cogs.snekbox.send_to_paste_service") + async def test_upload_output(self, mock_paste_util): """Upload the eval output to the URLs.paste_service.format(key="documents") endpoint.""" - key = "MarkDiamond" - resp = MagicMock() - resp.json = AsyncMock(return_value={"key": key}) - self.bot.http_session.post().__aenter__.return_value = resp - - self.assertEqual( - await self.cog.upload_output("My awesome output"), - constants.URLs.paste_service.format(key=key) - ) - self.bot.http_session.post.assert_called_with( - constants.URLs.paste_service.format(key="documents"), - data="My awesome output", - raise_for_status=True + await self.cog.upload_output("Test output.") + mock_paste_util.assert_called_once_with( + self.bot.http_session, "Test output.", extension="txt" ) def test_prepare_input(self): -- cgit v1.2.3 From 5d96e96a2e8982ec57c1a19d1a085ceccd35a6d7 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Fri, 8 May 2020 01:38:14 +0200 Subject: Add tests for `send_to_paste_service`. --- tests/bot/utils/test_init.py | 74 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/bot/utils/test_init.py diff --git a/tests/bot/utils/test_init.py b/tests/bot/utils/test_init.py new file mode 100644 index 000000000..f3a8f5939 --- /dev/null +++ b/tests/bot/utils/test_init.py @@ -0,0 +1,74 @@ +import logging +import unittest +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from aiohttp import ClientConnectorError + +from bot.utils import FAILED_REQUEST_ATTEMPTS, send_to_paste_service + + +class PasteTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.http_session = MagicMock() + + @patch("bot.utils.URLs.paste_service", "https://paste_service.com/{key}") + async def test_url_and_sent_contents(self): + """Correct url was used and post was called with expected data.""" + response = MagicMock( + json=AsyncMock(return_value={"key": ""}) + ) + self.http_session.post().__aenter__.return_value = response + self.http_session.post.reset_mock() + await send_to_paste_service(self.http_session, "Content") + self.http_session.post.assert_called_once_with("https://paste_service.com/documents", data="Content") + + @patch("bot.utils.URLs.paste_service", "https://paste_service.com/{key}") + async def test_paste_returns_correct_url_on_success(self): + """Url with specified extension is returned on successful requests.""" + key = "paste_key" + test_cases = ( + (f"https://paste_service.com/{key}.txt", "txt"), + (f"https://paste_service.com/{key}.py", "py"), + (f"https://paste_service.com/{key}", ""), + ) + response = MagicMock( + json=AsyncMock(return_value={"key": key}) + ) + self.http_session.post().__aenter__.return_value = response + + for expected_output, extension in test_cases: + with self.subTest(msg=f"Send contents with extension {repr(extension)}"): + self.assertEqual( + await send_to_paste_service(self.http_session, "", extension=extension), + expected_output + ) + + async def test_request_repeated_on_json_errors(self): + """Json with error message and invalid json are handled as errors and requests repeated.""" + test_cases = ({"message": "error"}, {"unexpected_key": None}, {}) + self.http_session.post().__aenter__.return_value = response = MagicMock() + self.http_session.post.reset_mock() + + for error_json in test_cases: + with self.subTest(error_json=error_json): + response.json = AsyncMock(return_value=error_json) + result = await send_to_paste_service(self.http_session, "") + self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) + self.assertIsNone(result) + + self.http_session.post.reset_mock() + + async def test_request_repeated_on_connection_errors(self): + """Requests are repeated in the case of connection errors.""" + self.http_session.post = MagicMock(side_effect=ClientConnectorError(Mock(), Mock())) + result = await send_to_paste_service(self.http_session, "") + self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) + self.assertIsNone(result) + + async def test_general_error_handled_and_request_repeated(self): + """All `Exception`s are handled, logged and request repeated.""" + self.http_session.post = MagicMock(side_effect=Exception) + result = await send_to_paste_service(self.http_session, "") + self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) + self.assertLogs("bot.utils", logging.ERROR) + self.assertIsNone(result) -- cgit v1.2.3 From 0b266169160a8368a3c7eba3fcdfb404b657232e Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sat, 30 May 2020 02:41:05 +0200 Subject: Truncate amount of lines in int eval output to 15. Previously the amount of newlines was checked and uploaded to the paste service if above 15 but the sent message was not truncated to only include that amount of lines. --- bot/cogs/eval.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index c75c1e55f..edb59d286 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -172,7 +172,15 @@ async def func(): # (None,) -> Any res = traceback.format_exc() out, embed = self._format(code, res) - if len(out) > 1500 or out.count("\n") > 15: + # Truncate output to max 15 lines or 1500 characters + newline_truncate_index = find_nth_occurrence(out, "\n", 15) + + if newline_truncate_index is None or newline_truncate_index > 1500: + truncate_index = 1500 + else: + truncate_index = newline_truncate_index + + if len(out) > truncate_index: paste_link = await send_to_paste_service(self.bot.http_session, out, extension="py") if paste_link is not None: paste_text = f"full contents at {paste_link}" @@ -180,7 +188,7 @@ async def func(): # (None,) -> Any paste_text = "failed to upload contents to paste service." await ctx.send( - f"```py\n{out[:1500]}\n```" + f"```py\n{out[:truncate_index]}\n```" f"... response truncated; {paste_text}", embed=embed ) @@ -212,6 +220,16 @@ async def func(): # (None,) -> Any await self._eval(ctx, code) +def find_nth_occurrence(string: str, substring: str, n: int) -> Optional[int]: + """Return index of `n`th occurrence of `substring` in `string`, or None if not found.""" + index = 0 + for _ in range(n): + index = string.find(substring, index+1) + if index == -1: + return None + return index + + def setup(bot: Bot) -> None: """Load the CodeEval cog.""" bot.add_cog(CodeEval(bot)) -- cgit v1.2.3 From c8f5f8597c8eb3cccf9cd7867fbc4777cc4b4f99 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sat, 30 May 2020 02:42:38 +0200 Subject: Strip empty lines from int eval output. The output generates trailing newlines, which can cause the output to be uploaded to the paste service in cases where it's not needed, as discord will automatically remove those in messages. --- bot/cogs/eval.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index edb59d286..5b7469bdf 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -172,6 +172,8 @@ async def func(): # (None,) -> Any res = traceback.format_exc() out, embed = self._format(code, res) + out = out.rstrip("\n") # Strip empty lines from output + # Truncate output to max 15 lines or 1500 characters newline_truncate_index = find_nth_occurrence(out, "\n", 15) -- cgit v1.2.3 From 31726ecf6127f6f7dddcab3d16f4a40b8b990f6c Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 15 Jul 2020 02:30:00 +0200 Subject: Move general helper functions to submodule. --- bot/utils/__init__.py | 17 ++--------------- bot/utils/helpers.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 15 deletions(-) create mode 100644 bot/utils/helpers.py diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 2c8d57bd5..7c29a5981 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,25 +1,17 @@ import logging -from abc import ABCMeta from typing import Optional from aiohttp import ClientConnectorError, ClientSession -from discord.ext.commands import CogMeta from bot.constants import URLs +from bot.utils.helpers import CogABCMeta, pad_base64 from bot.utils.redis_cache import RedisCache log = logging.getLogger(__name__) FAILED_REQUEST_ATTEMPTS = 3 - -__all__ = ['RedisCache', 'CogABCMeta', "send_to_paste_service"] - - -class CogABCMeta(CogMeta, ABCMeta): - """Metaclass for ABCs meant to be implemented as Cogs.""" - - pass +__all__ = ['RedisCache', 'CogABCMeta', "pad_base64", "send_to_paste_service"] async def send_to_paste_service(http_session: ClientSession, contents: str, *, extension: str = "") -> Optional[str]: @@ -64,8 +56,3 @@ async def send_to_paste_service(http_session: ClientSession, contents: str, *, e f"Got unexpected JSON response from paste service: {response_json}\n" f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." ) - - -def pad_base64(data: str) -> str: - """Return base64 `data` with padding characters to ensure its length is a multiple of 4.""" - return data + "=" * (-len(data) % 4) diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py new file mode 100644 index 000000000..cfbf47753 --- /dev/null +++ b/bot/utils/helpers.py @@ -0,0 +1,12 @@ +from abc import ABCMeta + +from discord.ext.commands import CogMeta + + +class CogABCMeta(CogMeta, ABCMeta): + """Metaclass for ABCs meant to be implemented as Cogs.""" + + +def pad_base64(data: str) -> str: + """Return base64 `data` with padding characters to ensure its length is a multiple of 4.""" + return data + "=" * (-len(data) % 4) -- cgit v1.2.3 From 326beebe9b097731a39ecc9868e5e1f2bd762aae Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 15 Jul 2020 02:33:42 +0200 Subject: Move `send_to_paste_service` to services submodule --- bot/utils/__init__.py | 55 +-------------------------------------------------- bot/utils/services.py | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 54 deletions(-) create mode 100644 bot/utils/services.py diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 7c29a5981..a950f3524 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,58 +1,5 @@ -import logging -from typing import Optional - -from aiohttp import ClientConnectorError, ClientSession - -from bot.constants import URLs from bot.utils.helpers import CogABCMeta, pad_base64 from bot.utils.redis_cache import RedisCache - -log = logging.getLogger(__name__) - -FAILED_REQUEST_ATTEMPTS = 3 +from bot.utils.services import send_to_paste_service __all__ = ['RedisCache', 'CogABCMeta', "pad_base64", "send_to_paste_service"] - - -async def send_to_paste_service(http_session: ClientSession, contents: str, *, extension: str = "") -> Optional[str]: - """ - Upload `contents` to the paste service. - - `http_session` should be the current running ClientSession from aiohttp - `extension` is added to the output URL - - When an error occurs, `None` is returned, otherwise the generated URL with the suffix. - """ - extension = extension and f".{extension}" - log.debug(f"Sending contents of size {len(contents.encode())} bytes to paste service.") - paste_url = URLs.paste_service.format(key="documents") - for attempt in range(1, FAILED_REQUEST_ATTEMPTS + 1): - try: - async with http_session.post(paste_url, data=contents) as response: - response_json = await response.json() - except ClientConnectorError: - log.warning( - f"Failed to connect to paste service at url {paste_url}, " - f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." - ) - continue - except Exception: - log.exception( - f"An unexpected error has occurred during handling of the request, " - f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." - ) - continue - - if "message" in response_json: - log.warning( - f"Paste service returned error {response_json['message']} with status code {response.status}, " - f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." - ) - continue - elif "key" in response_json: - log.info(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") - return URLs.paste_service.format(key=response_json['key']) + extension - log.warning( - f"Got unexpected JSON response from paste service: {response_json}\n" - f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." - ) diff --git a/bot/utils/services.py b/bot/utils/services.py new file mode 100644 index 000000000..087b9f969 --- /dev/null +++ b/bot/utils/services.py @@ -0,0 +1,54 @@ +import logging +from typing import Optional + +from aiohttp import ClientConnectorError, ClientSession + +from bot.constants import URLs + +log = logging.getLogger(__name__) + +FAILED_REQUEST_ATTEMPTS = 3 + + +async def send_to_paste_service(http_session: ClientSession, contents: str, *, extension: str = "") -> Optional[str]: + """ + Upload `contents` to the paste service. + + `http_session` should be the current running ClientSession from aiohttp + `extension` is added to the output URL + + When an error occurs, `None` is returned, otherwise the generated URL with the suffix. + """ + extension = extension and f".{extension}" + log.debug(f"Sending contents of size {len(contents.encode())} bytes to paste service.") + paste_url = URLs.paste_service.format(key="documents") + for attempt in range(1, FAILED_REQUEST_ATTEMPTS + 1): + try: + async with http_session.post(paste_url, data=contents) as response: + response_json = await response.json() + except ClientConnectorError: + log.warning( + f"Failed to connect to paste service at url {paste_url}, " + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + ) + continue + except Exception: + log.exception( + f"An unexpected error has occurred during handling of the request, " + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + ) + continue + + if "message" in response_json: + log.warning( + f"Paste service returned error {response_json['message']} with status code {response.status}, " + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + ) + continue + elif "key" in response_json: + log.info(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") + return URLs.paste_service.format(key=response_json['key']) + extension + log.warning( + f"Got unexpected JSON response from paste service: {response_json}\n" + f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." + ) -- cgit v1.2.3 From 2d1877cfb70304ff8d6bd24059459fa514d49e71 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 15 Jul 2020 02:37:28 +0200 Subject: Move `find_nth_occurrence` to utils helpers --- bot/cogs/eval.py | 12 +----------- bot/utils/__init__.py | 4 ++-- bot/utils/helpers.py | 11 +++++++++++ 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 52f7ffca7..23e5998d8 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -15,7 +15,7 @@ from bot.bot import Bot from bot.constants import Roles from bot.decorators import with_role from bot.interpreter import Interpreter -from bot.utils import send_to_paste_service +from bot.utils import find_nth_occurrence, send_to_paste_service log = logging.getLogger(__name__) @@ -222,16 +222,6 @@ async def func(): # (None,) -> Any await self._eval(ctx, code) -def find_nth_occurrence(string: str, substring: str, n: int) -> Optional[int]: - """Return index of `n`th occurrence of `substring` in `string`, or None if not found.""" - index = 0 - for _ in range(n): - index = string.find(substring, index+1) - if index == -1: - return None - return index - - def setup(bot: Bot) -> None: """Load the CodeEval cog.""" bot.add_cog(CodeEval(bot)) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index a950f3524..3e93fcb06 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,5 +1,5 @@ -from bot.utils.helpers import CogABCMeta, pad_base64 +from bot.utils.helpers import CogABCMeta, find_nth_occurrence, pad_base64 from bot.utils.redis_cache import RedisCache from bot.utils.services import send_to_paste_service -__all__ = ['RedisCache', 'CogABCMeta', "pad_base64", "send_to_paste_service"] +__all__ = ['RedisCache', 'CogABCMeta', 'find_nth_occurrence', 'pad_base64', 'send_to_paste_service'] diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py index cfbf47753..d9b60af07 100644 --- a/bot/utils/helpers.py +++ b/bot/utils/helpers.py @@ -1,4 +1,5 @@ from abc import ABCMeta +from typing import Optional from discord.ext.commands import CogMeta @@ -7,6 +8,16 @@ class CogABCMeta(CogMeta, ABCMeta): """Metaclass for ABCs meant to be implemented as Cogs.""" +def find_nth_occurrence(string: str, substring: str, n: int) -> Optional[int]: + """Return index of `n`th occurrence of `substring` in `string`, or None if not found.""" + index = 0 + for _ in range(n): + index = string.find(substring, index+1) + if index == -1: + return None + return index + + def pad_base64(data: str) -> str: """Return base64 `data` with padding characters to ensure its length is a multiple of 4.""" return data + "=" * (-len(data) % 4) -- cgit v1.2.3 From c115dcfb72e4d4a86b66bb84a72984705a2afcd4 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 15 Jul 2020 02:45:31 +0200 Subject: Change tests to work with the new file layout. 326beebe9b097731a39ecc9868e5e1f2bd762aae --- tests/bot/utils/test_init.py | 74 ---------------------------------------- tests/bot/utils/test_services.py | 74 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 74 deletions(-) delete mode 100644 tests/bot/utils/test_init.py create mode 100644 tests/bot/utils/test_services.py diff --git a/tests/bot/utils/test_init.py b/tests/bot/utils/test_init.py deleted file mode 100644 index f3a8f5939..000000000 --- a/tests/bot/utils/test_init.py +++ /dev/null @@ -1,74 +0,0 @@ -import logging -import unittest -from unittest.mock import AsyncMock, MagicMock, Mock, patch - -from aiohttp import ClientConnectorError - -from bot.utils import FAILED_REQUEST_ATTEMPTS, send_to_paste_service - - -class PasteTests(unittest.IsolatedAsyncioTestCase): - def setUp(self) -> None: - self.http_session = MagicMock() - - @patch("bot.utils.URLs.paste_service", "https://paste_service.com/{key}") - async def test_url_and_sent_contents(self): - """Correct url was used and post was called with expected data.""" - response = MagicMock( - json=AsyncMock(return_value={"key": ""}) - ) - self.http_session.post().__aenter__.return_value = response - self.http_session.post.reset_mock() - await send_to_paste_service(self.http_session, "Content") - self.http_session.post.assert_called_once_with("https://paste_service.com/documents", data="Content") - - @patch("bot.utils.URLs.paste_service", "https://paste_service.com/{key}") - async def test_paste_returns_correct_url_on_success(self): - """Url with specified extension is returned on successful requests.""" - key = "paste_key" - test_cases = ( - (f"https://paste_service.com/{key}.txt", "txt"), - (f"https://paste_service.com/{key}.py", "py"), - (f"https://paste_service.com/{key}", ""), - ) - response = MagicMock( - json=AsyncMock(return_value={"key": key}) - ) - self.http_session.post().__aenter__.return_value = response - - for expected_output, extension in test_cases: - with self.subTest(msg=f"Send contents with extension {repr(extension)}"): - self.assertEqual( - await send_to_paste_service(self.http_session, "", extension=extension), - expected_output - ) - - async def test_request_repeated_on_json_errors(self): - """Json with error message and invalid json are handled as errors and requests repeated.""" - test_cases = ({"message": "error"}, {"unexpected_key": None}, {}) - self.http_session.post().__aenter__.return_value = response = MagicMock() - self.http_session.post.reset_mock() - - for error_json in test_cases: - with self.subTest(error_json=error_json): - response.json = AsyncMock(return_value=error_json) - result = await send_to_paste_service(self.http_session, "") - self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) - self.assertIsNone(result) - - self.http_session.post.reset_mock() - - async def test_request_repeated_on_connection_errors(self): - """Requests are repeated in the case of connection errors.""" - self.http_session.post = MagicMock(side_effect=ClientConnectorError(Mock(), Mock())) - result = await send_to_paste_service(self.http_session, "") - self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) - self.assertIsNone(result) - - async def test_general_error_handled_and_request_repeated(self): - """All `Exception`s are handled, logged and request repeated.""" - self.http_session.post = MagicMock(side_effect=Exception) - result = await send_to_paste_service(self.http_session, "") - self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) - self.assertLogs("bot.utils", logging.ERROR) - self.assertIsNone(result) diff --git a/tests/bot/utils/test_services.py b/tests/bot/utils/test_services.py new file mode 100644 index 000000000..5e0855704 --- /dev/null +++ b/tests/bot/utils/test_services.py @@ -0,0 +1,74 @@ +import logging +import unittest +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from aiohttp import ClientConnectorError + +from bot.utils.services import FAILED_REQUEST_ATTEMPTS, send_to_paste_service + + +class PasteTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.http_session = MagicMock() + + @patch("bot.utils.services.URLs.paste_service", "https://paste_service.com/{key}") + async def test_url_and_sent_contents(self): + """Correct url was used and post was called with expected data.""" + response = MagicMock( + json=AsyncMock(return_value={"key": ""}) + ) + self.http_session.post().__aenter__.return_value = response + self.http_session.post.reset_mock() + await send_to_paste_service(self.http_session, "Content") + self.http_session.post.assert_called_once_with("https://paste_service.com/documents", data="Content") + + @patch("bot.utils.services.URLs.paste_service", "https://paste_service.com/{key}") + async def test_paste_returns_correct_url_on_success(self): + """Url with specified extension is returned on successful requests.""" + key = "paste_key" + test_cases = ( + (f"https://paste_service.com/{key}.txt", "txt"), + (f"https://paste_service.com/{key}.py", "py"), + (f"https://paste_service.com/{key}", ""), + ) + response = MagicMock( + json=AsyncMock(return_value={"key": key}) + ) + self.http_session.post().__aenter__.return_value = response + + for expected_output, extension in test_cases: + with self.subTest(msg=f"Send contents with extension {repr(extension)}"): + self.assertEqual( + await send_to_paste_service(self.http_session, "", extension=extension), + expected_output + ) + + async def test_request_repeated_on_json_errors(self): + """Json with error message and invalid json are handled as errors and requests repeated.""" + test_cases = ({"message": "error"}, {"unexpected_key": None}, {}) + self.http_session.post().__aenter__.return_value = response = MagicMock() + self.http_session.post.reset_mock() + + for error_json in test_cases: + with self.subTest(error_json=error_json): + response.json = AsyncMock(return_value=error_json) + result = await send_to_paste_service(self.http_session, "") + self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) + self.assertIsNone(result) + + self.http_session.post.reset_mock() + + async def test_request_repeated_on_connection_errors(self): + """Requests are repeated in the case of connection errors.""" + self.http_session.post = MagicMock(side_effect=ClientConnectorError(Mock(), Mock())) + result = await send_to_paste_service(self.http_session, "") + self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) + self.assertIsNone(result) + + async def test_general_error_handled_and_request_repeated(self): + """All `Exception`s are handled, logged and request repeated.""" + self.http_session.post = MagicMock(side_effect=Exception) + result = await send_to_paste_service(self.http_session, "") + self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) + self.assertLogs("bot.utils", logging.ERROR) + self.assertIsNone(result) -- cgit v1.2.3 From a2218956c881de81b41129e707091a43f6477d24 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sun, 2 Aug 2020 22:19:22 +0200 Subject: Verification: add initial on join message This message will be sent via direct message to each user who joins the guild. --- bot/cogs/verification.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index ae156cf70..2293cad28 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -12,6 +12,13 @@ from bot.utils.checks import InWhitelistCheckFailure, without_role_check log = logging.getLogger(__name__) +ON_JOIN_MESSAGE = f""" +Hello! Welcome to Python Discord! + +In order to send messages, you first have to accept our rules. To do so, please visit \ +<#{constants.Channels.verification}>. Thank you! +""" + WELCOME_MESSAGE = f""" Hello! Welcome to the server, and thanks for verifying yourself! -- cgit v1.2.3 From fad796101d5f641c0c4315244303c8727df0462f Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sun, 2 Aug 2020 22:25:18 +0200 Subject: Verification: adjust & rename welcome message Let's give it a better name so that it's clear when this message is sent. The initial words are adjusted to avoid repetition after the on join message. --- bot/cogs/verification.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 2293cad28..c10940817 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -19,8 +19,8 @@ In order to send messages, you first have to accept our rules. To do so, please <#{constants.Channels.verification}>. Thank you! """ -WELCOME_MESSAGE = f""" -Hello! Welcome to the server, and thanks for verifying yourself! +VERIFIED_MESSAGE = f""" +Thanks for verifying yourself! For your records, these are the documents you accepted: @@ -121,7 +121,7 @@ class Verification(Cog): log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.") await ctx.author.add_roles(Object(constants.Roles.verified), reason="Accepted the rules") try: - await ctx.author.send(WELCOME_MESSAGE) + await ctx.author.send(VERIFIED_MESSAGE) except Forbidden: log.info(f"Sending welcome message failed for {ctx.author}.") finally: -- cgit v1.2.3 From 0521af684f82fec50b46f744aebf76ccee88f318 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sun, 2 Aug 2020 23:06:21 +0200 Subject: Verification: send initial message on member join --- bot/cogs/verification.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index c10940817..1c1919bdf 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -1,7 +1,7 @@ import logging from contextlib import suppress -from discord import Colour, Forbidden, Message, NotFound, Object +from discord import Colour, Forbidden, Member, Message, NotFound, Object from discord.ext.commands import Cog, Context, command from bot import constants @@ -53,6 +53,16 @@ class Verification(Cog): """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + """Attempt to send initial direct message to each new member.""" + if member.guild.id != constants.Guild.id: + return # Only listen for PyDis events + + log.trace(f"Sending on join message to new member: {member.id}") + with suppress(Forbidden): + await member.send(ON_JOIN_MESSAGE) + @Cog.listener() async def on_message(self, message: Message) -> None: """Check new message event for messages to the checkpoint channel & process.""" -- cgit v1.2.3 From 226af0e676e072696dba503e3873deeaf73202ac Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 18:01:47 +0200 Subject: Verification: add @Unverified role to config --- bot/constants.py | 1 + config-default.yml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index cf4f3f666..cce64a7c4 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -454,6 +454,7 @@ class Roles(metaclass=YAMLGetter): partners: int python_community: int team_leaders: int + unverified: int verified: int # This is the Developers role on PyDis, here named verified for readability reasons. diff --git a/config-default.yml b/config-default.yml index fc093cc32..21a3eca87 100644 --- a/config-default.yml +++ b/config-default.yml @@ -225,8 +225,8 @@ guild: partners: 323426753857191936 python_community: &PY_COMMUNITY_ROLE 458226413825294336 - # This is the Developers role on PyDis, here named verified for readability reasons - verified: 352427296948486144 + unverified: 739794855945044069 + verified: 352427296948486144 # @Developers on PyDis # Staff admins: &ADMINS_ROLE 267628507062992896 -- cgit v1.2.3 From 5006105f14e575366575c1091af7cfd2b2da7abd Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 18:08:32 +0200 Subject: Verification: refactor `discord` imports Let's access these via the qualified name. The amount of imported names was starting to get unwieldy. --- bot/cogs/verification.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 1c1919bdf..f86356f33 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -1,7 +1,7 @@ import logging from contextlib import suppress -from discord import Colour, Forbidden, Member, Message, NotFound, Object +import discord from discord.ext.commands import Cog, Context, command from bot import constants @@ -54,17 +54,17 @@ class Verification(Cog): return self.bot.get_cog("ModLog") @Cog.listener() - async def on_member_join(self, member: Member) -> None: + async def on_member_join(self, member: discord.Member) -> None: """Attempt to send initial direct message to each new member.""" if member.guild.id != constants.Guild.id: return # Only listen for PyDis events log.trace(f"Sending on join message to new member: {member.id}") - with suppress(Forbidden): + with suppress(discord.Forbidden): await member.send(ON_JOIN_MESSAGE) @Cog.listener() - async def on_message(self, message: Message) -> None: + async def on_message(self, message: discord.Message) -> None: """Check new message event for messages to the checkpoint channel & process.""" if message.channel.id != constants.Channels.verification: return # Only listen for #checkpoint messages @@ -91,7 +91,7 @@ class Verification(Cog): # Send pretty mod log embed to mod-alerts await self.mod_log.send_log_message( icon_url=constants.Icons.filtering, - colour=Colour(constants.Colours.soft_red), + colour=discord.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"), @@ -120,7 +120,7 @@ class Verification(Cog): ) log.trace(f"Deleting the message posted by {ctx.author}") - with suppress(NotFound): + with suppress(discord.NotFound): await ctx.message.delete() @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True) @@ -129,14 +129,14 @@ class Verification(Cog): 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(constants.Roles.verified), reason="Accepted the rules") + await ctx.author.add_roles(discord.Object(constants.Roles.verified), reason="Accepted the rules") try: await ctx.author.send(VERIFIED_MESSAGE) - except Forbidden: + except discord.Forbidden: log.info(f"Sending welcome message failed for {ctx.author}.") finally: log.trace(f"Deleting accept message by {ctx.author}.") - with suppress(NotFound): + with suppress(discord.NotFound): self.mod_log.ignore(constants.Event.message_delete, ctx.message.id) await ctx.message.delete() @@ -156,7 +156,7 @@ class Verification(Cog): return log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.") - await ctx.author.add_roles(Object(constants.Roles.announcements), reason="Subscribed to announcements") + await ctx.author.add_roles(discord.Object(constants.Roles.announcements), reason="Subscribed to announcements") log.trace(f"Deleting the message posted by {ctx.author}.") @@ -180,7 +180,9 @@ class Verification(Cog): return log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.") - await ctx.author.remove_roles(Object(constants.Roles.announcements), reason="Unsubscribed from announcements") + await ctx.author.remove_roles( + discord.Object(constants.Roles.announcements), reason="Unsubscribed from announcements" + ) log.trace(f"Deleting the message posted by {ctx.author}.") -- cgit v1.2.3 From 8415ac8da18054be3258ee7816a70c58a3a9322a Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 18:09:06 +0200 Subject: Verification: define time constants --- bot/cogs/verification.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index f86356f33..95d92899b 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -39,6 +39,9 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! <#{constants.Channels.bot_commands}>. """ +UNVERIFIED_AFTER = 3 # Amount of days after which non-Developers receive the @Unverified role +KICKED_AFTER = 30 # Amount of days after which non-Developers get kicked from the guild + BOT_MESSAGE_DELETE_DELAY = 10 -- cgit v1.2.3 From f0e0dcc599f974bb563e298199fcf5a76b1cfbe7 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 18:13:37 +0200 Subject: Verification: implement `check_users` coroutine See docstring for details. The coroutine will be registered as a task at a later point. --- bot/cogs/verification.py | 60 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 95d92899b..ea4874450 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -1,5 +1,8 @@ +import asyncio import logging +import typing as t from contextlib import suppress +from datetime import datetime, timedelta import discord from discord.ext.commands import Cog, Context, command @@ -51,6 +54,61 @@ class Verification(Cog): def __init__(self, bot: Bot): self.bot = bot + async def _kick_members(self, members: t.Set[discord.Member]) -> int: + """Kick `members` from the PyDis guild.""" + ... + + async def _give_role(self, members: t.Set[discord.Member], role: discord.Role) -> int: + """Give `role` to all `members`.""" + ... + + async def check_users(self) -> None: + """ + Periodically check in on the verification status of PyDis members. + + This coroutine performs two actions: + * Find members who have not verified for `UNVERIFIED_AFTER` and give them the @Unverified role + * Find members who have not verified for `KICKED_AFTER` and kick them from the guild + + Within the body of this coroutine, we only select the members for each action. The work is then + delegated to `_kick_members` and `_give_role`. After each run, a report is sent via modlog. + """ + await self.bot.wait_until_guild_available() # Ensure cache is ready + pydis = self.bot.get_guild(constants.Guild.id) + + unverified = pydis.get_role(constants.Roles.unverified) + current_dt = datetime.utcnow() # Discord timestamps are UTC + + # Users to be given the @Unverified role, and those to be kicked, these should be entirely disjoint + for_role, for_kick = set(), set() + + log.debug("Checking verification status of guild members") + for member in pydis.members: + + # Skip all bots and users for which we don't know their join date + # This should be extremely rare, but can happen according to `joined_at` docs + if member.bot or member.joined_at is None: + continue + + # Now we check roles to determine whether this user has already verified + unverified_roles = {unverified, pydis.default_role} # Verified users have at least one more role + if set(member.roles) - unverified_roles: + continue + + # At this point, we know that `member` is an unverified user, and we will decide what + # to do with them based on time passed since their join date + since_join = current_dt - member.joined_at + + if since_join > timedelta(days=KICKED_AFTER): + for_kick.add(member) # User should be removed from the guild + + elif since_join > timedelta(days=UNVERIFIED_AFTER) and unverified not in member.roles: + for_role.add(member) # User should be given the @Unverified role + + log.debug(f"{len(for_role)} users will be given the {unverified} role, {len(for_kick)} users will be kicked") + n_kicks = await self._kick_members(for_kick) + n_roles = await self._give_role(for_role, unverified) + @property def mod_log(self) -> ModLog: """Get currently loaded ModLog cog instance.""" @@ -184,7 +242,7 @@ class Verification(Cog): log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.") await ctx.author.remove_roles( - discord.Object(constants.Roles.announcements), reason="Unsubscribed from announcements" + discord.Object(constants.Roles.announcements), reason="Unsubscribed from announcements" ) log.trace(f"Deleting the message posted by {ctx.author}.") -- cgit v1.2.3 From 6840dbe5539cd5a094c65b2d09ddda227ad2ca30 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 18:35:17 +0200 Subject: Verification: implement `_give_role` helper --- bot/cogs/verification.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index ea4874450..683e60ddb 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -59,8 +59,27 @@ class Verification(Cog): ... async def _give_role(self, members: t.Set[discord.Member], role: discord.Role) -> int: - """Give `role` to all `members`.""" - ... + """ + Give `role` to all `members`. + + Returns the amount of successful requests. Status codes of unsuccessful requests + are logged at info level. + """ + log.info(f"Assigning {role} role to {len(members)} members (not verified after {UNVERIFIED_AFTER} days)") + n_success, bad_statuses = 0, set() + + for member in members: + try: + await member.add_roles(role, reason=f"User has not verified in {UNVERIFIED_AFTER} days") + except discord.HTTPException as http_exc: + bad_statuses.add(http_exc.status) + else: + n_success += 1 + + if bad_statuses: + log.info(f"Failed to assign {len(members) - n_success} roles due to following statuses: {bad_statuses}") + + return n_success async def check_users(self) -> None: """ -- cgit v1.2.3 From d807324eef804ec4ea002ef34063046e4fdaeca5 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 18:40:03 +0200 Subject: Verification: implement `_kick_members` helper --- bot/cogs/verification.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 683e60ddb..94c21a568 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -1,4 +1,3 @@ -import asyncio import logging import typing as t from contextlib import suppress @@ -55,8 +54,27 @@ class Verification(Cog): self.bot = bot async def _kick_members(self, members: t.Set[discord.Member]) -> int: - """Kick `members` from the PyDis guild.""" - ... + """ + Kick `members` from the PyDis guild. + + Note that this is a potentially destructive operation. Returns the amount of successful + requests. Failed requests are logged at info level. + """ + log.info(f"Kicking {len(members)} members from the guild (not verified after {KICKED_AFTER} days)") + n_kicked, bad_statuses = 0, set() + + for member in members: + try: + await member.kick(reason=f"User has not verified in {KICKED_AFTER} days") + except discord.HTTPException as http_exc: + bad_statuses.add(http_exc.status) + else: + n_kicked += 1 + + if bad_statuses: + log.info(f"Failed to kick {len(members) - n_kicked} members due to following statuses: {bad_statuses}") + + return n_kicked async def _give_role(self, members: t.Set[discord.Member], role: discord.Role) -> int: """ -- cgit v1.2.3 From 59f8ec77fa25519d1bc81052af7c2cc6460cedad Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 18:42:45 +0200 Subject: Verification: implement `_verify_kick` helper This will be used to guard the call to `_kick_members`. --- bot/cogs/verification.py | 63 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 94c21a568..85a0e3ec4 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -1,3 +1,4 @@ +import asyncio import logging import typing as t from contextlib import suppress @@ -44,6 +45,11 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! UNVERIFIED_AFTER = 3 # Amount of days after which non-Developers receive the @Unverified role KICKED_AFTER = 30 # Amount of days after which non-Developers get kicked from the guild +# Number in range [0, 1] determining the percentage of unverified users that are safe +# to be kicked from the guild in one batch, any larger amount will require staff confirmation, +# set this to 0 to require explicit approval for batches of any size +KICK_CONFIRMATION_THRESHOLD = 0 + BOT_MESSAGE_DELETE_DELAY = 10 @@ -53,6 +59,63 @@ class Verification(Cog): def __init__(self, bot: Bot): self.bot = bot + async def _verify_kick(self, n_members: int) -> bool: + """ + Determine whether `n_members` is a reasonable amount of members to kick. + + First, `n_members` is checked against the size of the PyDis guild. If `n_members` are + more than `KICK_CONFIRMATION_THRESHOLD` of the guild, the operation must be confirmed + by staff in #core-dev. Otherwise, the operation is seen as safe. + """ + log.debug(f"Checking whether {n_members} members are safe to kick") + + await self.bot.wait_until_guild_available() # Ensure cache is populated before we grab the guild + pydis = self.bot.get_guild(constants.Guild.id) + + percentage = n_members / len(pydis.members) + if percentage < KICK_CONFIRMATION_THRESHOLD: + log.debug(f"Kicking {percentage:.2%} of the guild's population is seen as safe") + return True + + # Since `n_members` is a suspiciously large number, we will ask for confirmation + log.debug("Amount of users is too large, requesting staff confirmation") + + core_devs = pydis.get_channel(constants.Channels.dev_core) + confirmation_msg = await core_devs.send( + f"Verification determined that `{n_members}` members should be kicked as they haven't verified in " + f"`{KICKED_AFTER}` days. This is `{percentage:.2%}` of the guild's population. Proceed?" + ) + + options = (constants.Emojis.incident_actioned, constants.Emojis.incident_unactioned) + for option in options: + await confirmation_msg.add_reaction(option) + + def check(reaction: discord.Reaction, user: discord.User) -> bool: + """Check whether `reaction` is a valid reaction to `confirmation_msg`.""" + return ( + reaction.message.id == confirmation_msg.id # Reacted to `confirmation_msg` + and str(reaction.emoji) in options # With one of `options` + and not user.bot # By a human + ) + + timeout = 60 * 5 # Seconds, i.e. 5 minutes + try: + choice, _ = await self.bot.wait_for("reaction_add", check=check, timeout=timeout) + except asyncio.TimeoutError: + log.debug("Staff prompt not answered, aborting operation") + return False + finally: + await confirmation_msg.clear_reactions() + + result = str(choice) == constants.Emojis.incident_actioned + log.debug(f"Received answer: {choice}, result: {result}") + + # Edit the prompt message to reflect the final choice + await confirmation_msg.edit( + content=f"Request to kick `{n_members}` members was {'authorized' if result else 'denied'}!" + ) + return result + async def _kick_members(self, members: t.Set[discord.Member]) -> int: """ Kick `members` from the PyDis guild. -- cgit v1.2.3 From 56f9e84b3bfa07eab4f4623e861cdefada92cdce Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 19:12:31 +0200 Subject: Verification: repurpose & rename `_check_users` Let's only use this function to check on the guild status. It can be exposed via a command in the future. Name adjusted to be more accurate w.r.t. Discord terminology. --- bot/cogs/verification.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 85a0e3ec4..4a9983ac8 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -162,16 +162,15 @@ class Verification(Cog): return n_success - async def check_users(self) -> None: + async def _check_members(self) -> t.Tuple[t.Set[discord.Member], t.Set[discord.Member]]: """ - Periodically check in on the verification status of PyDis members. + Check in on the verification status of PyDis members. - This coroutine performs two actions: - * Find members who have not verified for `UNVERIFIED_AFTER` and give them the @Unverified role - * Find members who have not verified for `KICKED_AFTER` and kick them from the guild + This coroutine finds two sets of users: + * Not verified after `UNVERIFIED_AFTER` days, should be given the @Unverified role + * Not verified after `KICKED_AFTER` days, should be kicked from the guild - Within the body of this coroutine, we only select the members for each action. The work is then - delegated to `_kick_members` and `_give_role`. After each run, a report is sent via modlog. + These sets are always disjoint, i.e. share no common members. """ await self.bot.wait_until_guild_available() # Ensure cache is ready pydis = self.bot.get_guild(constants.Guild.id) @@ -205,9 +204,8 @@ class Verification(Cog): elif since_join > timedelta(days=UNVERIFIED_AFTER) and unverified not in member.roles: for_role.add(member) # User should be given the @Unverified role - log.debug(f"{len(for_role)} users will be given the {unverified} role, {len(for_kick)} users will be kicked") - n_kicks = await self._kick_members(for_kick) - n_roles = await self._give_role(for_role, unverified) + log.debug(f"Found {len(for_role)} users for {unverified} role, {len(for_kick)} users to be kicked") + return for_role, for_kick @property def mod_log(self) -> ModLog: -- cgit v1.2.3 From 4b1100500681d1f4b670f91fe0566e4e85c8371b Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 19:33:42 +0200 Subject: Verification: create task to update unverified members --- bot/cogs/verification.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 4a9983ac8..cc8d8eb7d 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -5,6 +5,7 @@ from contextlib import suppress from datetime import datetime, timedelta import discord +from discord.ext import tasks from discord.ext.commands import Cog, Context, command from bot import constants @@ -207,6 +208,42 @@ class Verification(Cog): log.debug(f"Found {len(for_role)} users for {unverified} role, {len(for_kick)} users to be kicked") return for_role, for_kick + @tasks.loop(minutes=30) + async def update_unverified_members(self) -> None: + """ + Periodically call `_check_members` and update unverified members accordingly. + + After each run, a summary will be sent to the modlog channel. If a suspiciously high + amount of members to be kicked is found, the operation is guarded by `_verify_kick`. + """ + log.info("Updating unverified guild members") + + await self.bot.wait_until_guild_available() + unverified = self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.unverified) + + for_role, for_kick = await self._check_members() + + if not for_role: + role_report = f"Found no users to be assigned the {unverified.mention} role." + else: + n_roles = await self._give_role(for_role, unverified) + role_report = f"Assigned {unverified.mention} role to `{n_roles}`/`{len(for_role)}` members." + + if not for_kick: + kick_report = "Found no users to be kicked." + elif not await self._verify_kick(len(for_kick)): + kick_report = f"Not authorized to kick `{len(for_kick)}` members." + else: + n_kicks = await self._kick_members(for_kick) + kick_report = f"Kicked `{n_kicks}`/`{len(for_kick)}` members from the guild." + + await self.mod_log.send_log_message( + icon_url=self.bot.user.avatar_url, + colour=discord.Colour.blurple(), + title="Verification system", + text=f"{kick_report}\n{role_report}", + ) + @property def mod_log(self) -> ModLog: """Get currently loaded ModLog cog instance.""" -- cgit v1.2.3 From 1305380f2552622cd51e1e22dded80ed2791af44 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 19:41:43 +0200 Subject: Verification: add region comments & move property to top Cog is getting large so let's allow collapsing related bits. --- bot/cogs/verification.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index cc8d8eb7d..951736761 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -60,6 +60,13 @@ class Verification(Cog): def __init__(self, bot: Bot): self.bot = bot + @property + def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + # region: automatically update unverified users + async def _verify_kick(self, n_members: int) -> bool: """ Determine whether `n_members` is a reasonable amount of members to kick. @@ -244,10 +251,8 @@ class Verification(Cog): text=f"{kick_report}\n{role_report}", ) - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") + # endregion + # region: listeners @Cog.listener() async def on_member_join(self, member: discord.Member) -> None: @@ -319,6 +324,9 @@ class Verification(Cog): with suppress(discord.NotFound): await ctx.message.delete() + # endregion + # region: accept and subscribe commands + @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True) @without_role(constants.Roles.verified) @in_whitelist(channels=(constants.Channels.verification,)) @@ -386,6 +394,9 @@ class Verification(Cog): f"{ctx.author.mention} Unsubscribed from <#{constants.Channels.announcements}> notifications." ) + # endregion + # region: miscellaneous + # This cannot be static (must have a __func__ attribute). async def cog_command_error(self, ctx: Context, error: Exception) -> None: """Check for & ignore any InWhitelistCheckFailure.""" @@ -400,6 +411,8 @@ class Verification(Cog): else: return True + # endregion + def setup(bot: Bot) -> None: """Load the Verification cog.""" -- cgit v1.2.3 From 18f2f1b8817f0209922112a0576b9b0377c2958d Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 19:44:17 +0200 Subject: Verification: schedule member update task Turns out that it's necessary to cancel the task manually. Otherwise, duplicate tasks can be running concurrently should the extension be reloaded. --- bot/cogs/verification.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 951736761..0534e8d1e 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -57,9 +57,20 @@ BOT_MESSAGE_DELETE_DELAY = 10 class Verification(Cog): """User verification and role self-management.""" - def __init__(self, bot: Bot): + def __init__(self, bot: Bot) -> None: + """Start `update_unverified_members` task.""" self.bot = bot + self.update_unverified_members.start() + + def cog_unload(self) -> None: + """ + Kill `update_unverified_members` task. + + This is necessary, the task is not automatically cancelled on cog unload. + """ + self.update_unverified_members.cancel() + @property def mod_log(self) -> ModLog: """Get currently loaded ModLog cog instance.""" -- cgit v1.2.3 From ef0e2049b64a0ba61878161d4ac7edb6015acbc2 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 20:07:24 +0200 Subject: Verification: make authorization message ping core devs --- bot/cogs/verification.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 0534e8d1e..803cb055b 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -101,8 +101,9 @@ class Verification(Cog): core_devs = pydis.get_channel(constants.Channels.dev_core) confirmation_msg = await core_devs.send( - f"Verification determined that `{n_members}` members should be kicked as they haven't verified in " - f"`{KICKED_AFTER}` days. This is `{percentage:.2%}` of the guild's population. Proceed?" + f"<@&{constants.Roles.core_developers}> Verification determined that `{n_members}` members should " + f"be kicked as they haven't verified in `{KICKED_AFTER}` days. This is `{percentage:.2%}` of the " + f"guild's population. Proceed?" ) options = (constants.Emojis.incident_actioned, constants.Emojis.incident_unactioned) -- cgit v1.2.3 From f4721ba580c5d47d0cd5fb5beb60e6af54098244 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 20:11:01 +0200 Subject: Verification: move time constants above messages Allows referencing the constants within the message bodies. --- bot/cogs/verification.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 803cb055b..7244d041d 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -16,6 +16,16 @@ from bot.utils.checks import InWhitelistCheckFailure, without_role_check log = logging.getLogger(__name__) +UNVERIFIED_AFTER = 3 # Amount of days after which non-Developers receive the @Unverified role +KICKED_AFTER = 30 # Amount of days after which non-Developers get kicked from the guild + +# Number in range [0, 1] determining the percentage of unverified users that are safe +# to be kicked from the guild in one batch, any larger amount will require staff confirmation, +# set this to 0 to require explicit approval for batches of any size +KICK_CONFIRMATION_THRESHOLD = 0 + +BOT_MESSAGE_DELETE_DELAY = 10 + ON_JOIN_MESSAGE = f""" Hello! Welcome to Python Discord! @@ -43,16 +53,6 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! <#{constants.Channels.bot_commands}>. """ -UNVERIFIED_AFTER = 3 # Amount of days after which non-Developers receive the @Unverified role -KICKED_AFTER = 30 # Amount of days after which non-Developers get kicked from the guild - -# Number in range [0, 1] determining the percentage of unverified users that are safe -# to be kicked from the guild in one batch, any larger amount will require staff confirmation, -# set this to 0 to require explicit approval for batches of any size -KICK_CONFIRMATION_THRESHOLD = 0 - -BOT_MESSAGE_DELETE_DELAY = 10 - class Verification(Cog): """User verification and role self-management.""" -- cgit v1.2.3 From f101f5608dd840ae79db353b562a7c2f800533b2 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 20:12:48 +0200 Subject: Verification: add reminder ping message & frequency --- bot/cogs/verification.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 7244d041d..a01c25010 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -53,6 +53,17 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! <#{constants.Channels.bot_commands}>. """ +REMINDER_MESSAGE = f""" +<@&{constants.Roles.unverified}> + +Welcome to Python Discord! Please read the documents mentioned above and type `!accept` to gain permissions \ +to send messages in the community! + +You will be kicked if you don't verify within `{KICKED_AFTER}` days. +""" + +REMINDER_FREQUENCY = 28 # Hours to wait between sending `REMINDER_MESSAGE` + class Verification(Cog): """User verification and role self-management.""" -- cgit v1.2.3 From a8a583068e0ecacd9f2279a4e24fea0f5920fb51 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 20:13:59 +0200 Subject: Verification: comment message uses --- bot/cogs/verification.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index a01c25010..3502fe5b5 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -26,6 +26,7 @@ KICK_CONFIRMATION_THRESHOLD = 0 BOT_MESSAGE_DELETE_DELAY = 10 +# Sent via DMs once user joins the guild ON_JOIN_MESSAGE = f""" Hello! Welcome to Python Discord! @@ -33,6 +34,7 @@ In order to send messages, you first have to accept our rules. To do so, please <#{constants.Channels.verification}>. Thank you! """ +# Sent via DMs once user verifies VERIFIED_MESSAGE = f""" Thanks for verifying yourself! @@ -53,6 +55,7 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! <#{constants.Channels.bot_commands}>. """ +# Sent periodically in the verification channel REMINDER_MESSAGE = f""" <@&{constants.Roles.unverified}> -- cgit v1.2.3 From b1d761cecf5612d49de47c50994e12ab45b20e5e Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 20:19:00 +0200 Subject: Verification: add reminder cache --- bot/cogs/verification.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 3502fe5b5..e32224554 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -13,6 +13,7 @@ from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.decorators import in_whitelist, without_role from bot.utils.checks import InWhitelistCheckFailure, without_role_check +from bot.utils.redis_cache import RedisCache log = logging.getLogger(__name__) @@ -71,6 +72,10 @@ REMINDER_FREQUENCY = 28 # Hours to wait between sending `REMINDER_MESSAGE` class Verification(Cog): """User verification and role self-management.""" + # Cache last sent `REMINDER_MESSAGE` id + # RedisCache[str, discord.Message.id] + reminder_cache = RedisCache() + def __init__(self, bot: Bot) -> None: """Start `update_unverified_members` task.""" self.bot = bot -- cgit v1.2.3 From 39d7b32b258def7f9fcf01bebb4f82013ae2de76 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 21:26:47 +0200 Subject: Verification: ignore verification reminder message event --- bot/cogs/verification.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index e32224554..ca7631db2 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -301,6 +301,9 @@ class Verification(Cog): if message.channel.id != constants.Channels.verification: return # Only listen for #checkpoint messages + if message.content == REMINDER_MESSAGE.strip(): + return # Ignore bots own verification reminder + if message.author.bot: # They're a bot, delete their message after the delay. await message.delete(delay=BOT_MESSAGE_DELETE_DELAY) -- cgit v1.2.3 From 8f548f158e17481245809801b1285b17af279fb4 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 22:12:12 +0200 Subject: Verification: implement unverified role ping task We're making good use of d.py's tasks framework. RedisCache is used to persist the reminder message ids, which can conveniently be converted into timestamps. It is therefore trivial to determine the time to sleep before the first ping. After that, the bot simply pings every n hours. --- bot/cogs/verification.py | 52 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index ca7631db2..42088896d 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -7,6 +7,7 @@ from datetime import datetime, timedelta import discord from discord.ext import tasks from discord.ext.commands import Cog, Context, command +from discord.utils import snowflake_time from bot import constants from bot.bot import Bot @@ -282,6 +283,57 @@ class Verification(Cog): text=f"{kick_report}\n{role_report}", ) + # endregion + # region: periodically ping @Unverified + + @tasks.loop(hours=REMINDER_FREQUENCY) + async def ping_unverified(self) -> None: + """ + Delete latest `REMINDER_MESSAGE` and send it again. + + This utilizes RedisCache to persist the latest reminder message id. + """ + await self.bot.wait_until_guild_available() + verification = self.bot.get_guild(constants.Guild.id).get_channel(constants.Channels.verification) + + last_reminder: t.Optional[int] = await self.reminder_cache.get("last_reminder") + + if last_reminder is not None: + log.trace(f"Found verification reminder message in cache, deleting: {last_reminder}") + + with suppress(discord.HTTPException): # If something goes wrong, just ignore it + await self.bot.http.delete_message(verification.id, last_reminder) + + log.trace("Sending verification reminder") + new_reminder = await verification.send(REMINDER_MESSAGE) + + await self.reminder_cache.set("last_reminder", new_reminder.id) + + @ping_unverified.before_loop + async def _before_first_ping(self) -> None: + """ + Sleep until `REMINDER_MESSAGE` should be sent again. + + If latest reminder is not cached, exit instantly. Otherwise, wait wait until the + configured `REMINDER_FREQUENCY` has passed. + """ + last_reminder: t.Optional[int] = await self.reminder_cache.get("last_reminder") + + if last_reminder is None: + log.trace("Latest verification reminder message not cached, task will not wait") + return + + # Convert cached message id into a timestamp + time_since = datetime.utcnow() - snowflake_time(last_reminder) + log.trace(f"Time since latest verification reminder: {time_since}") + + to_sleep = timedelta(hours=REMINDER_FREQUENCY) - time_since + log.trace(f"Time to sleep until next ping: {to_sleep}") + + # Delta can be negative if `REMINDER_FREQUENCY` has already passed + secs = max(to_sleep.total_seconds(), 0) + await asyncio.sleep(secs) + # endregion # region: listeners -- cgit v1.2.3 From 04db02199c72dc0855da8ac90cb514a750dd1f22 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 4 Aug 2020 22:13:15 +0200 Subject: Verification: schedule ping task --- bot/cogs/verification.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 42088896d..5586be040 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -78,18 +78,20 @@ class Verification(Cog): reminder_cache = RedisCache() def __init__(self, bot: Bot) -> None: - """Start `update_unverified_members` task.""" + """Start internal tasks.""" self.bot = bot self.update_unverified_members.start() + self.ping_unverified.start() def cog_unload(self) -> None: """ - Kill `update_unverified_members` task. + Cancel internal tasks. - This is necessary, the task is not automatically cancelled on cog unload. + This is necessary, as tasks are not automatically cancelled on cog unload. """ self.update_unverified_members.cancel() + self.ping_unverified.cancel() @property def mod_log(self) -> ModLog: -- cgit v1.2.3 From fb042c89cd519a41f39eba1559df58fc31a97832 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 6 Aug 2020 10:53:57 +0200 Subject: Verification: remove unverified role on accept --- bot/cogs/verification.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 5586be040..5bc4f81c1 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -422,6 +422,11 @@ class Verification(Cog): """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(discord.Object(constants.Roles.verified), reason="Accepted the rules") + + if constants.Roles.unverified in [role.id for role in ctx.author.roles]: + log.debug(f"Removing Unverified role from: {ctx.author}") + await ctx.author.remove_roles(discord.Object(constants.Roles.unverified)) + try: await ctx.author.send(VERIFIED_MESSAGE) except discord.Forbidden: -- cgit v1.2.3 From 78c19d2f57a41acce231d6950b45dde0fa8832c0 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 6 Aug 2020 13:04:53 +0200 Subject: Verification: add stats collection --- bot/cogs/verification.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 5bc4f81c1..64ff4d8e6 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -176,6 +176,8 @@ class Verification(Cog): else: n_kicked += 1 + self.bot.stats.incr("verification.kicked", count=n_kicked) + if bad_statuses: log.info(f"Failed to kick {len(members) - n_kicked} members due to following statuses: {bad_statuses}") @@ -415,6 +417,30 @@ class Verification(Cog): # endregion # region: accept and subscribe commands + def _bump_verified_stats(self, verified_member: discord.Member) -> None: + """ + Increment verification stats for `verified_member`. + + Each member falls into one of the three categories: + * Verified within 24 hours after joining + * Does not have @Unverified role yet + * Does have @Unverified role + + Stats for member kicking are handled separately. + """ + if verified_member.joined_at is None: # Docs mention this can happen + return + + if (datetime.utcnow() - verified_member.joined_at) < timedelta(hours=24): + category = "accepted_on_day_one" + elif constants.Roles.unverified not in [role.id for role in verified_member.roles]: + category = "accepted_before_unverified" + else: + category = "accepted_after_unverified" + + log.trace(f"Bumping verification stats in category: {category}") + self.bot.stats.incr(f"verification.{category}") + @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True) @without_role(constants.Roles.verified) @in_whitelist(channels=(constants.Channels.verification,)) @@ -423,6 +449,8 @@ class Verification(Cog): log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.") await ctx.author.add_roles(discord.Object(constants.Roles.verified), reason="Accepted the rules") + self._bump_verified_stats(ctx.author) # This checks for @Unverified so make sure it's not yet removed + if constants.Roles.unverified in [role.id for role in ctx.author.roles]: log.debug(f"Removing Unverified role from: {ctx.author}") await ctx.author.remove_roles(discord.Object(constants.Roles.unverified)) -- cgit v1.2.3 From b1c800c623f90f46c4ecaff8da2269efcd04ee05 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 6 Aug 2020 13:12:06 +0200 Subject: Verification: disable burst shared filter in verification We will begin pinging users in the verification channel, prompting them to join. This can cause a surge of activity that may trigger the filter. A better solution would involve allowing per-filter channel config, but after internal discussion this is seen as unnecessary for now. --- bot/rules/burst_shared.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/bot/rules/burst_shared.py b/bot/rules/burst_shared.py index bbe9271b3..0e66df69c 100644 --- a/bot/rules/burst_shared.py +++ b/bot/rules/burst_shared.py @@ -2,11 +2,20 @@ from typing import Dict, Iterable, List, Optional, Tuple from discord import Member, Message +from bot.constants import Channels + async def apply( last_message: Message, recent_messages: List[Message], config: Dict[str, int] ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """Detects repeated messages sent by multiple users.""" + """ + Detects repeated messages sent by multiple users. + + This filter never triggers in the verification channel. + """ + if last_message.channel.id == Channels.verification: + return + total_recent = len(recent_messages) if total_recent > config['max']: -- cgit v1.2.3 From 0a9769c4fa8d378fb7949212e8733531e9c1591a Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 6 Aug 2020 13:52:57 +0200 Subject: Verification: enable role pings --- bot/cogs/verification.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 64ff4d8e6..2872e704a 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -69,6 +69,13 @@ You will be kicked if you don't verify within `{KICKED_AFTER}` days. REMINDER_FREQUENCY = 28 # Hours to wait between sending `REMINDER_MESSAGE` +MENTION_CORE_DEVS = discord.AllowedMentions( + everyone=False, roles=[discord.Object(constants.Roles.core_developers)] +) +MENTION_UNVERIFIED = discord.AllowedMentions( + everyone=False, roles=[discord.Object(constants.Roles.unverified)] +) + class Verification(Cog): """User verification and role self-management.""" @@ -125,7 +132,8 @@ class Verification(Cog): confirmation_msg = await core_devs.send( f"<@&{constants.Roles.core_developers}> Verification determined that `{n_members}` members should " f"be kicked as they haven't verified in `{KICKED_AFTER}` days. This is `{percentage:.2%}` of the " - f"guild's population. Proceed?" + f"guild's population. Proceed?", + allowed_mentions=MENTION_CORE_DEVS, ) options = (constants.Emojis.incident_actioned, constants.Emojis.incident_unactioned) @@ -309,7 +317,7 @@ class Verification(Cog): await self.bot.http.delete_message(verification.id, last_reminder) log.trace("Sending verification reminder") - new_reminder = await verification.send(REMINDER_MESSAGE) + new_reminder = await verification.send(REMINDER_MESSAGE, allowed_mentions=MENTION_UNVERIFIED) await self.reminder_cache.set("last_reminder", new_reminder.id) -- cgit v1.2.3 From c9f2a2accea4a380eccec9f14fe389e230144242 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 6 Aug 2020 14:02:27 +0200 Subject: Verification: send DM to kicked members --- bot/cogs/verification.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 2872e704a..ac488497a 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -57,6 +57,12 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! <#{constants.Channels.bot_commands}>. """ +# Sent via DMs to users kicked for failing to verify +KICKED_MESSAGE = f""" +Hi! You have been automatically kicked from Python Discord as you have failed to accept our rules \ +within `{KICKED_AFTER}` days. If this was an accident, please feel free to join again. +""" + # Sent periodically in the verification channel REMINDER_MESSAGE = f""" <@&{constants.Roles.unverified}> @@ -177,6 +183,8 @@ class Verification(Cog): n_kicked, bad_statuses = 0, set() for member in members: + with suppress(discord.Forbidden): + await member.send(KICKED_MESSAGE) # Send message while user is still in guild try: await member.kick(reason=f"User has not verified in {KICKED_AFTER} days") except discord.HTTPException as http_exc: -- cgit v1.2.3 From c7eebaa1b70ad6bdd6a84bd0e980d5ea66f0002f Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 6 Aug 2020 14:15:47 +0200 Subject: Verification: bump confirmation threshold to 1% --- 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 ac488497a..23b61a337 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -24,7 +24,7 @@ KICKED_AFTER = 30 # Amount of days after which non-Developers get kicked from t # Number in range [0, 1] determining the percentage of unverified users that are safe # to be kicked from the guild in one batch, any larger amount will require staff confirmation, # set this to 0 to require explicit approval for batches of any size -KICK_CONFIRMATION_THRESHOLD = 0 +KICK_CONFIRMATION_THRESHOLD = 0.01 # 1% BOT_MESSAGE_DELETE_DELAY = 10 -- cgit v1.2.3 From 92cbc03204a3fde78b919fec9d5f17144d24bd83 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 6 Aug 2020 14:38:57 +0200 Subject: Verification: make on-join message more accurate It now explains that new users can only see a limited amount of public channels, and that there will be more once they verify. Co-authored-by: Sebastiaan Zeeff --- bot/cogs/verification.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 23b61a337..ff4b358c7 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -32,8 +32,10 @@ BOT_MESSAGE_DELETE_DELAY = 10 ON_JOIN_MESSAGE = f""" Hello! Welcome to Python Discord! -In order to send messages, you first have to accept our rules. To do so, please visit \ -<#{constants.Channels.verification}>. Thank you! +As a new user, you have read-only access to a few select channels to give you a taste of what our server is like. + +In order to see the rest of the channels and to send messages, you first have to accept our rules. To do so, \ +please visit <#{constants.Channels.verification}>. Thank you! """ # Sent via DMs once user verifies -- cgit v1.2.3 From 16eec3d2d69af5178b03fb574b0f277dbcf1dea8 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 6 Aug 2020 18:17:38 +0200 Subject: Verification: extend cog docstring --- bot/cogs/verification.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index ff4b358c7..963a2369e 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -86,7 +86,23 @@ MENTION_UNVERIFIED = discord.AllowedMentions( class Verification(Cog): - """User verification and role self-management.""" + """ + User verification and role management. + + There are two internal tasks in this cog: + + * `update_unverified_members` + * Unverified members are given the @Unverified role after `UNVERIFIED_AFTER` days + * Unverified members are kicked after `UNVERIFIED_AFTER` days + + * `ping_unverified` + * Periodically ping the @Unverified role in the verification channel + + Statistics are collected in the 'verification.' namespace. + + Additionally, this cog offers the !accept, !subscribe and !unsubscribe commands, + and keeps the verification channel clean by deleting messages. + """ # Cache last sent `REMINDER_MESSAGE` id # RedisCache[str, discord.Message.id] -- cgit v1.2.3 From fe50d6457081e0e6ef86d821bcfab81a8a164ca5 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 6 Aug 2020 23:45:53 +0200 Subject: Verification: add command interface for task management Allow checking whether tasks are running, starting them, and stopping them. Currently, the tasks cannot be started or stopped separately. It is not believed that we would need such a level of granularity. Calling `cancel` on a task that isn't running is a no-op. --- bot/cogs/verification.py | 62 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 963a2369e..152118d92 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -6,13 +6,13 @@ from datetime import datetime, timedelta import discord from discord.ext import tasks -from discord.ext.commands import Cog, Context, command +from discord.ext.commands import Cog, Context, command, group from discord.utils import snowflake_time from bot import constants from bot.bot import Bot from bot.cogs.moderation import ModLog -from bot.decorators import in_whitelist, without_role +from bot.decorators import in_whitelist, with_role, without_role from bot.utils.checks import InWhitelistCheckFailure, without_role_check from bot.utils.redis_cache import RedisCache @@ -448,6 +448,64 @@ class Verification(Cog): with suppress(discord.NotFound): await ctx.message.delete() + # endregion + # region: task management commands + + @with_role(*constants.MODERATION_ROLES) + @group(name="verification") + async def verification_group(self, ctx: Context) -> None: + """Manage internal verification tasks.""" + if ctx.invoked_subcommand is None: + await ctx.send_help(ctx.command) + + @verification_group.command(name="status") + async def status_cmd(self, ctx: Context) -> None: + """Check whether verification tasks are running.""" + log.trace("Checking status of verification tasks") + + if self.update_unverified_members.is_running(): + update_status = f"{constants.Emojis.incident_actioned} Member update task is running." + else: + update_status = f"{constants.Emojis.incident_unactioned} Member update task is **not** running." + + mention = f"<@&{constants.Roles.unverified}>" + if self.ping_unverified.is_running(): + ping_status = f"{constants.Emojis.incident_actioned} Ping {mention} is running." + else: + ping_status = f"{constants.Emojis.incident_unactioned} Ping {mention} is **not** running." + + embed = discord.Embed( + title="Verification system", + description=f"{update_status}\n{ping_status}", + colour=discord.Colour.blurple(), + ) + await ctx.send(embed=embed) + + @verification_group.command(name="start") + async def start_cmd(self, ctx: Context) -> None: + """Start verification tasks if they are not already running.""" + log.info("Starting verification tasks") + + if not self.update_unverified_members.is_running(): + self.update_unverified_members.start() + + if not self.ping_unverified.is_running(): + self.ping_unverified.start() + + colour = discord.Colour.blurple() + await ctx.send(embed=discord.Embed(title="Verification system", description="Done. :ok_hand:", colour=colour)) + + @verification_group.command(name="stop", aliases=["kill"]) + async def stop_cmd(self, ctx: Context) -> None: + """Stop verification tasks.""" + log.info("Stopping verification tasks") + + self.update_unverified_members.cancel() + self.ping_unverified.cancel() + + colour = discord.Colour.blurple() + await ctx.send(embed=discord.Embed(title="Verification system", description="Tasks canceled.", colour=colour)) + # endregion # region: accept and subscribe commands -- cgit v1.2.3 From 2d24e4730e0f9678c0d7833c2332c7f0821eb7e2 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 6 Aug 2020 23:48:06 +0200 Subject: Verification: persist task settings in Redis If tasks are stopped manually, they will not automatically restart on cog reload or bot restart. Using `maybe_start_tasks` is necessary because we cannot interface with Redis from a sync context. We're using 1 and 0 because RedisCache does not currently permit bool values due to a typestring conversion bug. --- bot/cogs/verification.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 152118d92..b4dc1f145 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -111,9 +111,7 @@ class Verification(Cog): def __init__(self, bot: Bot) -> None: """Start internal tasks.""" self.bot = bot - - self.update_unverified_members.start() - self.ping_unverified.start() + self.bot.loop.create_task(self.maybe_start_tasks()) def cog_unload(self) -> None: """ @@ -129,6 +127,20 @@ class Verification(Cog): """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") + async def maybe_start_tasks(self) -> None: + """ + Poll Redis to check whether internal tasks should start. + + Redis must be interfaced with from an async function. + """ + log.trace("Checking whether background tasks should begin") + setting: t.Optional[int] = await self.reminder_cache.get("tasks_running") # This can be None if never set + + if setting: + log.trace("Background tasks will be started") + self.update_unverified_members.start() + self.ping_unverified.start() + # region: automatically update unverified users async def _verify_kick(self, n_members: int) -> bool: @@ -492,6 +504,8 @@ class Verification(Cog): if not self.ping_unverified.is_running(): self.ping_unverified.start() + await self.reminder_cache.set("tasks_running", 1) + colour = discord.Colour.blurple() await ctx.send(embed=discord.Embed(title="Verification system", description="Done. :ok_hand:", colour=colour)) @@ -503,6 +517,8 @@ class Verification(Cog): self.update_unverified_members.cancel() self.ping_unverified.cancel() + await self.reminder_cache.set("tasks_running", 0) + colour = discord.Colour.blurple() await ctx.send(embed=discord.Embed(title="Verification system", description="Tasks canceled.", colour=colour)) -- cgit v1.2.3 From 80063705dc2264c1a320100f3620b5a384780699 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 7 Aug 2020 10:21:28 +0200 Subject: Verification: rename cache & document new use --- bot/cogs/verification.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index b4dc1f145..6b245d574 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -100,13 +100,19 @@ class Verification(Cog): Statistics are collected in the 'verification.' namespace. + Moderators+ can use the `verification` command group to start or stop both internal + tasks, if necessary. Settings are persisted in Redis across sessions. + Additionally, this cog offers the !accept, !subscribe and !unsubscribe commands, and keeps the verification channel clean by deleting messages. """ - # Cache last sent `REMINDER_MESSAGE` id - # RedisCache[str, discord.Message.id] - reminder_cache = RedisCache() + # Persist task settings & last sent `REMINDER_MESSAGE` id + # RedisCache[ + # "tasks_running": int (0 or 1), + # "last_reminder": int (discord.Message.id), + # ] + task_cache = RedisCache() def __init__(self, bot: Bot) -> None: """Start internal tasks.""" @@ -134,7 +140,7 @@ class Verification(Cog): Redis must be interfaced with from an async function. """ log.trace("Checking whether background tasks should begin") - setting: t.Optional[int] = await self.reminder_cache.get("tasks_running") # This can be None if never set + setting: t.Optional[int] = await self.task_cache.get("tasks_running") # This can be None if never set if setting: log.trace("Background tasks will be started") @@ -346,7 +352,7 @@ class Verification(Cog): await self.bot.wait_until_guild_available() verification = self.bot.get_guild(constants.Guild.id).get_channel(constants.Channels.verification) - last_reminder: t.Optional[int] = await self.reminder_cache.get("last_reminder") + last_reminder: t.Optional[int] = await self.task_cache.get("last_reminder") if last_reminder is not None: log.trace(f"Found verification reminder message in cache, deleting: {last_reminder}") @@ -357,7 +363,7 @@ class Verification(Cog): log.trace("Sending verification reminder") new_reminder = await verification.send(REMINDER_MESSAGE, allowed_mentions=MENTION_UNVERIFIED) - await self.reminder_cache.set("last_reminder", new_reminder.id) + await self.task_cache.set("last_reminder", new_reminder.id) @ping_unverified.before_loop async def _before_first_ping(self) -> None: @@ -367,7 +373,7 @@ class Verification(Cog): If latest reminder is not cached, exit instantly. Otherwise, wait wait until the configured `REMINDER_FREQUENCY` has passed. """ - last_reminder: t.Optional[int] = await self.reminder_cache.get("last_reminder") + last_reminder: t.Optional[int] = await self.task_cache.get("last_reminder") if last_reminder is None: log.trace("Latest verification reminder message not cached, task will not wait") @@ -504,7 +510,7 @@ class Verification(Cog): if not self.ping_unverified.is_running(): self.ping_unverified.start() - await self.reminder_cache.set("tasks_running", 1) + await self.task_cache.set("tasks_running", 1) colour = discord.Colour.blurple() await ctx.send(embed=discord.Embed(title="Verification system", description="Done. :ok_hand:", colour=colour)) @@ -517,7 +523,7 @@ class Verification(Cog): self.update_unverified_members.cancel() self.ping_unverified.cancel() - await self.reminder_cache.set("tasks_running", 0) + await self.task_cache.set("tasks_running", 0) colour = discord.Colour.blurple() await ctx.send(embed=discord.Embed(title="Verification system", description="Tasks canceled.", colour=colour)) -- cgit v1.2.3 From 6be9f0d24caa792b23d8f93ce9d87e48df3e92a5 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 7 Aug 2020 15:22:56 +0200 Subject: Verification: address member update race condition In an edge case, the `_kick_members` and `_give_role` could act on a member who has verified *after* being marked by `_check_members` as unverified. To address this, we perform one additional check just before sending the request. Testing seems to indicate that the `discord.Member` instance get updates as appropriate, so this should at least reduce the chances of such a race happening to very close to nil. --- bot/cogs/verification.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 6b245d574..ed03b0a14 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -85,6 +85,20 @@ MENTION_UNVERIFIED = discord.AllowedMentions( ) +def is_verified(member: discord.Member) -> bool: + """ + Check whether `member` is considered verified. + + Members are considered verified if they have at least 1 role other than + the default role (@everyone) and the @Unverified role. + """ + unverified_roles = { + member.guild.get_role(constants.Roles.unverified), + member.guild.default_role, + } + return bool(set(member.roles) - unverified_roles) + + class Verification(Cog): """ User verification and role management. @@ -219,6 +233,8 @@ class Verification(Cog): n_kicked, bad_statuses = 0, set() for member in members: + if is_verified(member): # Member could have verified in the meantime + continue with suppress(discord.Forbidden): await member.send(KICKED_MESSAGE) # Send message while user is still in guild try: @@ -246,6 +262,8 @@ class Verification(Cog): n_success, bad_statuses = 0, set() for member in members: + if is_verified(member): # Member could have verified in the meantime + continue try: await member.add_roles(role, reason=f"User has not verified in {UNVERIFIED_AFTER} days") except discord.HTTPException as http_exc: @@ -280,14 +298,9 @@ class Verification(Cog): log.debug("Checking verification status of guild members") for member in pydis.members: - # Skip all bots and users for which we don't know their join date - # This should be extremely rare, but can happen according to `joined_at` docs - if member.bot or member.joined_at is None: - continue - - # Now we check roles to determine whether this user has already verified - unverified_roles = {unverified, pydis.default_role} # Verified users have at least one more role - if set(member.roles) - unverified_roles: + # Skip verified members, bots, and members for which we do not know their join date, + # this should be extremely rare but docs mention that it can happen + if is_verified(member) or member.bot or member.joined_at is None: continue # At this point, we know that `member` is an unverified user, and we will decide what -- cgit v1.2.3 From 553b6a9118dc21634d0fd78fdb58f98cb02c3c7f Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 8 Aug 2020 11:02:51 +0200 Subject: Verification: improve `is_verified` check This just reads better. Co-authored-by: MarkKoz --- 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 ed03b0a14..d4064cff7 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -96,7 +96,7 @@ def is_verified(member: discord.Member) -> bool: member.guild.get_role(constants.Roles.unverified), member.guild.default_role, } - return bool(set(member.roles) - unverified_roles) + return len(set(member.roles) - unverified_roles) > 0 class Verification(Cog): -- cgit v1.2.3 From f3c16f77d812be50c2b7bed4c046cd67f3b9b761 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 8 Aug 2020 11:04:24 +0200 Subject: Verification: only take reactions from core devs Co-authored-by: MarkKoz --- bot/cogs/verification.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index d4064cff7..da2f81e2d 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -196,12 +196,14 @@ class Verification(Cog): for option in options: await confirmation_msg.add_reaction(option) + core_dev_ids = [member.id for member in pydis.get_role(constants.Roles.core_developers).members] + def check(reaction: discord.Reaction, user: discord.User) -> bool: """Check whether `reaction` is a valid reaction to `confirmation_msg`.""" return ( reaction.message.id == confirmation_msg.id # Reacted to `confirmation_msg` and str(reaction.emoji) in options # With one of `options` - and not user.bot # By a human + and user.id in core_dev_ids # By a core developer ) timeout = 60 * 5 # Seconds, i.e. 5 minutes -- cgit v1.2.3 From 9b91847950a31f094c92a77974edc19d7766f514 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 8 Aug 2020 11:05:57 +0200 Subject: Verification: widen set type annotation Co-authored-by: MarkKoz --- bot/cogs/verification.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index da2f81e2d..9dc65da1c 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -224,7 +224,7 @@ class Verification(Cog): ) return result - async def _kick_members(self, members: t.Set[discord.Member]) -> int: + async def _kick_members(self, members: t.Collection[discord.Member]) -> int: """ Kick `members` from the PyDis guild. @@ -253,7 +253,7 @@ class Verification(Cog): return n_kicked - async def _give_role(self, members: t.Set[discord.Member], role: discord.Role) -> int: + async def _give_role(self, members: t.Collection[discord.Member], role: discord.Role) -> int: """ Give `role` to all `members`. -- cgit v1.2.3 From 174796a9bf8fcb117f38e8d6dc1a4b17c3849334 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 8 Aug 2020 11:50:50 +0200 Subject: Verification: strip reminder message once and for all Co-authored-by: MarkKoz --- bot/cogs/verification.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 9dc65da1c..a22b91e5d 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -73,7 +73,7 @@ Welcome to Python Discord! Please read the documents mentioned above and type `! to send messages in the community! You will be kicked if you don't verify within `{KICKED_AFTER}` days. -""" +""".strip() REMINDER_FREQUENCY = 28 # Hours to wait between sending `REMINDER_MESSAGE` @@ -424,7 +424,7 @@ class Verification(Cog): if message.channel.id != constants.Channels.verification: return # Only listen for #checkpoint messages - if message.content == REMINDER_MESSAGE.strip(): + if message.content == REMINDER_MESSAGE: return # Ignore bots own verification reminder if message.author.bot: -- cgit v1.2.3 From 286cdccb21ed035d697128c2212d88368cb48e8d Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 8 Aug 2020 13:56:24 +0200 Subject: Verification: improve confirmation message handling Suppress errors coming from Discord when changing the confirmation message in case it gets deleted, or something else goes wrong. This commit also adds either the ok hand or the warning emoji to the edited message content, as with the guild syncer confirmation. Co-authored-by: MarkKoz --- bot/cogs/verification.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index a22b91e5d..cbf2c51c3 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -213,15 +213,21 @@ class Verification(Cog): log.debug("Staff prompt not answered, aborting operation") return False finally: - await confirmation_msg.clear_reactions() + with suppress(discord.HTTPException): + await confirmation_msg.clear_reactions() result = str(choice) == constants.Emojis.incident_actioned log.debug(f"Received answer: {choice}, result: {result}") # Edit the prompt message to reflect the final choice - await confirmation_msg.edit( - content=f"Request to kick `{n_members}` members was {'authorized' if result else 'denied'}!" - ) + if result is True: + result_msg = f":ok_hand: Request to kick `{n_members}` members was authorized!" + else: + result_msg = f":warning: Request to kick `{n_members}` members was denied!" + + with suppress(discord.HTTPException): + await confirmation_msg.edit(content=result_msg) + return result async def _kick_members(self, members: t.Collection[discord.Member]) -> int: -- cgit v1.2.3 From 257048446a1e37c1bbdad424f8a8465f0491ca83 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 12 Aug 2020 12:11:47 -0700 Subject: Filtering: ignore errors for duplicate offensive messages The error happens when a filter is triggered by a message edit. Fixes #1099 Fixes BOT-6B --- bot/cogs/filtering.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 93cc1c655..99b659bff 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -11,6 +11,7 @@ from discord import Colour, HTTPException, Member, Message, NotFound, TextChanne from discord.ext.commands import Cog from discord.utils import escape_markdown +from bot.api import ResponseCodeError from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import ( @@ -301,9 +302,16 @@ class Filtering(Cog): 'delete_date': delete_date } - await self.bot.api_client.post('bot/offensive-messages', json=data) - self.schedule_msg_delete(data) - log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") + try: + await self.bot.api_client.post('bot/offensive-messages', json=data) + except ResponseCodeError as e: + if e.status == 400 and "already exists" in e.response_json.get("id", [""])[0]: + log.debug(f"Offensive message {msg.id} already exists.") + else: + log.error(f"Offensive message {msg.id} failed to post: {e}") + else: + self.schedule_msg_delete(data) + log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") if is_private: channel_str = "via DM" -- cgit v1.2.3 From 1958978e71dc5bd9e4ae007091db72de147afc12 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 13 Aug 2020 18:49:47 +0200 Subject: Verification: add `_send_requests` helper Generic request dispatch method to avoid code duplication with error handling & bad status logging. --- bot/cogs/verification.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index cbf2c51c3..e89f491cf 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -84,6 +84,9 @@ MENTION_UNVERIFIED = discord.AllowedMentions( everyone=False, roles=[discord.Object(constants.Roles.unverified)] ) +# An async function taking a Member param +Request = t.Callable[[discord.Member], t.Awaitable] + def is_verified(member: discord.Member) -> bool: """ @@ -230,6 +233,33 @@ class Verification(Cog): return result + async def _send_requests(self, members: t.Collection[discord.Member], request: Request) -> int: + """ + Pass `members` one by one to `request` handling Discord exceptions. + + This coroutine serves as a generic `request` executor for kicking members and adding + roles, as it allows us to define the error handling logic in one place only. + + Returns the amount of successful requests. Failed requests are logged at info level. + """ + log.info(f"Sending {len(members)} requests") + n_success, bad_statuses = 0, set() + + for member in members: + if is_verified(member): # Member could have verified in the meantime + continue + try: + await request(member) + except discord.HTTPException as http_exc: + bad_statuses.add(http_exc.status) + else: + n_success += 1 + + if bad_statuses: + log.info(f"Failed to send {len(members) - n_success} requests due to following statuses: {bad_statuses}") + + return n_success + async def _kick_members(self, members: t.Collection[discord.Member]) -> int: """ Kick `members` from the PyDis guild. -- cgit v1.2.3 From dc70a018bfbdb5546c05af0a60e0c58dad5e4de1 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 13 Aug 2020 18:51:12 +0200 Subject: Verification: adjust coroutines to use generic dispatch --- bot/cogs/verification.py | 43 +++++++++++-------------------------------- 1 file changed, 11 insertions(+), 32 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index e89f491cf..8f1a773a8 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -264,55 +264,34 @@ class Verification(Cog): """ Kick `members` from the PyDis guild. - Note that this is a potentially destructive operation. Returns the amount of successful - requests. Failed requests are logged at info level. + Note that this is a potentially destructive operation. Returns the amount of successful requests. """ log.info(f"Kicking {len(members)} members from the guild (not verified after {KICKED_AFTER} days)") - n_kicked, bad_statuses = 0, set() - for member in members: - if is_verified(member): # Member could have verified in the meantime - continue + async def kick_request(member: discord.Member) -> None: + """Send `KICKED_MESSAGE` to `member` and kick them from the guild.""" with suppress(discord.Forbidden): - await member.send(KICKED_MESSAGE) # Send message while user is still in guild - try: - await member.kick(reason=f"User has not verified in {KICKED_AFTER} days") - except discord.HTTPException as http_exc: - bad_statuses.add(http_exc.status) - else: - n_kicked += 1 + await member.send(KICKED_MESSAGE) + await member.kick(reason=f"User has not verified in {KICKED_AFTER} days") + n_kicked = await self._send_requests(members, kick_request) self.bot.stats.incr("verification.kicked", count=n_kicked) - if bad_statuses: - log.info(f"Failed to kick {len(members) - n_kicked} members due to following statuses: {bad_statuses}") - return n_kicked async def _give_role(self, members: t.Collection[discord.Member], role: discord.Role) -> int: """ Give `role` to all `members`. - Returns the amount of successful requests. Status codes of unsuccessful requests - are logged at info level. + Returns the amount of successful requests. """ log.info(f"Assigning {role} role to {len(members)} members (not verified after {UNVERIFIED_AFTER} days)") - n_success, bad_statuses = 0, set() - for member in members: - if is_verified(member): # Member could have verified in the meantime - continue - try: - await member.add_roles(role, reason=f"User has not verified in {UNVERIFIED_AFTER} days") - except discord.HTTPException as http_exc: - bad_statuses.add(http_exc.status) - else: - n_success += 1 + async def role_request(member: discord.Member) -> None: + """Add `role` to `member`.""" + await member.add_roles(role, reason=f"User has not verified in {UNVERIFIED_AFTER} days") - if bad_statuses: - log.info(f"Failed to assign {len(members) - n_success} roles due to following statuses: {bad_statuses}") - - return n_success + return await self._send_requests(members, role_request) async def _check_members(self) -> t.Tuple[t.Set[discord.Member], t.Set[discord.Member]]: """ -- cgit v1.2.3 From 689e5203f993604f1cb40be65e22b79ceb04d382 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 14 Aug 2020 19:39:07 +0200 Subject: Verification: pause request execution after each batch The Limit values are mostly assumptions, as this feature is very difficult to test at scale. Please see docstring amendmends for further information. --- bot/cogs/verification.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 8f1a773a8..14c0abfda 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -88,6 +88,13 @@ MENTION_UNVERIFIED = discord.AllowedMentions( Request = t.Callable[[discord.Member], t.Awaitable] +class Limit(t.NamedTuple): + """Composition over config for throttling requests.""" + + batch_size: int # Amount of requests after which to pause + sleep_secs: int # Sleep this many seconds after each batch + + def is_verified(member: discord.Member) -> bool: """ Check whether `member` is considered verified. @@ -233,19 +240,22 @@ class Verification(Cog): return result - async def _send_requests(self, members: t.Collection[discord.Member], request: Request) -> int: + async def _send_requests(self, members: t.Collection[discord.Member], request: Request, limit: Limit) -> int: """ Pass `members` one by one to `request` handling Discord exceptions. This coroutine serves as a generic `request` executor for kicking members and adding roles, as it allows us to define the error handling logic in one place only. + To avoid rate-limits, pass a `limit` configuring the batch size and the amount of seconds + to sleep between batches. + Returns the amount of successful requests. Failed requests are logged at info level. """ log.info(f"Sending {len(members)} requests") n_success, bad_statuses = 0, set() - for member in members: + for progress, member in enumerate(members, start=1): if is_verified(member): # Member could have verified in the meantime continue try: @@ -255,6 +265,10 @@ class Verification(Cog): else: n_success += 1 + if progress % limit.batch_size == 0: + log.trace(f"Processed {progress} requests, pausing for {limit.sleep_secs} seconds") + await asyncio.sleep(limit.sleep_secs) + if bad_statuses: log.info(f"Failed to send {len(members) - n_success} requests due to following statuses: {bad_statuses}") @@ -264,6 +278,9 @@ class Verification(Cog): """ Kick `members` from the PyDis guild. + Due to strict ratelimits on sending messages (120 requests / 60 secs), we sleep for a second + after each 2 requests to allow breathing room for other features. + Note that this is a potentially destructive operation. Returns the amount of successful requests. """ log.info(f"Kicking {len(members)} members from the guild (not verified after {KICKED_AFTER} days)") @@ -274,7 +291,7 @@ class Verification(Cog): await member.send(KICKED_MESSAGE) await member.kick(reason=f"User has not verified in {KICKED_AFTER} days") - n_kicked = await self._send_requests(members, kick_request) + n_kicked = await self._send_requests(members, kick_request, Limit(batch_size=2, sleep_secs=1)) self.bot.stats.incr("verification.kicked", count=n_kicked) return n_kicked @@ -283,6 +300,8 @@ class Verification(Cog): """ Give `role` to all `members`. + We pause for a second after batches of 25 requests to ensure ratelimits aren't exceeded. + Returns the amount of successful requests. """ log.info(f"Assigning {role} role to {len(members)} members (not verified after {UNVERIFIED_AFTER} days)") @@ -291,7 +310,7 @@ class Verification(Cog): """Add `role` to `member`.""" await member.add_roles(role, reason=f"User has not verified in {UNVERIFIED_AFTER} days") - return await self._send_requests(members, role_request) + return await self._send_requests(members, role_request, Limit(batch_size=25, sleep_secs=1)) async def _check_members(self) -> t.Tuple[t.Set[discord.Member], t.Set[discord.Member]]: """ -- cgit v1.2.3 From e374c00e2a846cfd9f8c5468b5c2dab599c1f1e2 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 14 Aug 2020 21:09:51 +0100 Subject: Add constants for badges --- bot/constants.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index d01dcb0fc..f3db80279 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -268,6 +268,17 @@ class Emojis(metaclass=YAMLGetter): status_idle: str status_dnd: str + badge_staff: str + badge_partner: str + badge_hypesquad: str + badge_bug_hunter: str + badge_hypesquad_bravery: str + badge_hypesquad_brilliance: str + badge_hypesquad_balance: str + badge_early_supporter: str + badge_bug_hunter_level_2: str + badge_verified_bot_developer: str + incident_actioned: str incident_unactioned: str incident_investigating: str -- cgit v1.2.3 From f87db13ee549c64723086948b101292da93934d8 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 14 Aug 2020 21:13:05 +0100 Subject: Add YAML values for badges --- config-default.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/config-default.yml b/config-default.yml index e3ba9fb05..8c0092e76 100644 --- a/config-default.yml +++ b/config-default.yml @@ -38,6 +38,17 @@ style: status_dnd: "<:status_dnd:470326272082313216>" status_offline: "<:status_offline:470326266537705472>" + badge_staff: "<:discord_staff:743882896498098226>" + badge_partner: "<:partner:743882897131569323>" + badge_hypesquad: "<:hypesquad_events:743882896892362873>" + badge_bug_hunter: "<:bug_hunter_lvl1:743882896372269137>" + badge_hypesquad_bravery: "<:hypesquad_bravery:743882896745693335>" + badge_hypesquad_brilliance: "<:hypesquad_brilliance:743882896938631248>" + badge_hypesquad_balance: "<:hypesquad_balance:743882896460480625>" + badge_early_supporter: "<:early_supporter:743882896909140058>" + badge_bug_hunter_level_2: "<:bug_hunter_lvl2:743882896611344505>" + badge_verified_bot_developer: "<:verified_bot_dev:743882897299210310>" + incident_actioned: "<:incident_actioned:719645530128646266>" incident_unactioned: "<:incident_unactioned:719645583245180960>" incident_investigating: "<:incident_investigating:719645658671480924>" -- cgit v1.2.3 From ab133d914e21a298ddd743db578e7d4a2e33120c Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 14 Aug 2020 21:14:20 +0100 Subject: Add badges & status to user command --- bot/cogs/information.py | 95 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 67 insertions(+), 28 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 8982196d1..34a85a86b 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -4,7 +4,7 @@ import pprint import textwrap from collections import Counter, defaultdict from string import Template -from typing import Any, Mapping, Optional, Union +from typing import Any, Mapping, Optional, Tuple, Union from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils from discord.abc import GuildChannel @@ -184,6 +184,18 @@ class Information(Cog): await ctx.send(embed=embed) + @staticmethod + def status_to_emoji(status: Status) -> str: + """Convert a Discord status into the relevant emoji.""" + if status is Status.offline: + return constants.Emojis.status_offline + elif status is Status.dnd: + return constants.Emojis.status_dnd + elif status is Status.idle: + return constants.Emojis.status_idle + else: + return constants.Emojis.status_online + @command(name="user", aliases=["user_info", "member", "member_info"]) async def user_info(self, ctx: Context, user: Member = None) -> None: """Returns info about a user.""" @@ -223,41 +235,68 @@ class Information(Cog): if user.nick: name = f"{user.nick} ({name})" + badges = "" + + for badge, is_set in user.public_flags: + if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}")): + badges += emoji + " " + joined = time_since(user.joined_at, max_units=3) roles = ", ".join(role.mention for role in user.roles[1:]) - description = [ - textwrap.dedent(f""" - **User Information** - Created: {created} - Profile: {user.mention} - ID: {user.id} - {custom_status} - **Member Information** - Joined: {joined} - Roles: {roles or None} - """).strip() + desktop_status = self.status_to_emoji(user.desktop_status) + web_status = self.status_to_emoji(user.web_status) + mobile_status = self.status_to_emoji(user.mobile_status) + + fields = [ + ( + "User information", + textwrap.dedent(f""" + Created: {created} + Profile: {user.mention} + ID: {user.id} + {custom_status} + """).strip() + ), + ( + "Member information", + textwrap.dedent(f""" + Joined: {joined} + Roles: {roles or None} + """).strip() + ), + ( + "Status", + textwrap.dedent(f""" + Desktop: {desktop_status} + Web: {web_status} + Mobile: {mobile_status} + """).strip() + ) ] # Show more verbose output in moderation channels for infractions and nominations if ctx.channel.id in constants.MODERATION_CHANNELS: - description.append(await self.expanded_user_infraction_counts(user)) - description.append(await self.user_nomination_counts(user)) + fields.append(await self.expanded_user_infraction_counts(user)) + fields.append(await self.user_nomination_counts(user)) else: - description.append(await self.basic_user_infraction_counts(user)) + fields.append(await self.basic_user_infraction_counts(user)) # Let's build the embed now embed = Embed( title=name, - description="\n\n".join(description) + description=badges ) + for field_name, field_content in fields: + embed.add_field(name=field_name, value=field_content, inline=False) + embed.set_thumbnail(url=user.avatar_url_as(static_format="png")) embed.colour = user.top_role.colour if roles else Colour.blurple() return embed - async def basic_user_infraction_counts(self, member: Member) -> str: + async def basic_user_infraction_counts(self, member: Member) -> Tuple[str, str]: """Gets the total and active infraction counts for the given `member`.""" infractions = await self.bot.api_client.get( 'bot/infractions', @@ -270,11 +309,11 @@ class Information(Cog): total_infractions = len(infractions) active_infractions = sum(infraction['active'] for infraction in infractions) - infraction_output = f"**Infractions**\nTotal: {total_infractions}\nActive: {active_infractions}" + infraction_output = f"Total: {total_infractions}\nActive: {active_infractions}" - return infraction_output + return "Infractions", infraction_output - async def expanded_user_infraction_counts(self, member: Member) -> str: + async def expanded_user_infraction_counts(self, member: Member) -> Tuple[str, str]: """ Gets expanded infraction counts for the given `member`. @@ -288,9 +327,9 @@ class Information(Cog): } ) - infraction_output = ["**Infractions**"] + infraction_output = [] if not infractions: - infraction_output.append("This user has never received an infraction.") + infraction_output.append("No infractions") else: # Count infractions split by `type` and `active` status for this user infraction_types = set() @@ -313,9 +352,9 @@ class Information(Cog): infraction_output.append(line) - return "\n".join(infraction_output) + return "Infractions", "\n".join(infraction_output) - async def user_nomination_counts(self, member: Member) -> str: + async def user_nomination_counts(self, member: Member) -> Tuple[str, str]: """Gets the active and historical nomination counts for the given `member`.""" nominations = await self.bot.api_client.get( 'bot/nominations', @@ -324,21 +363,21 @@ class Information(Cog): } ) - output = ["**Nominations**"] + output = [] if not nominations: - output.append("This user has never been nominated.") + output.append("No nominations") else: count = len(nominations) is_currently_nominated = any(nomination["active"] for nomination in nominations) nomination_noun = "nomination" if count == 1 else "nominations" if is_currently_nominated: - output.append(f"This user is **currently** nominated ({count} {nomination_noun} in total).") + output.append(f"This user is **currently** nominated\n({count} {nomination_noun} in total)") else: output.append(f"This user has {count} historical {nomination_noun}, but is currently not nominated.") - return "\n".join(output) + return "Nominations", "\n".join(output) def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str: """Format a mapping to be readable to a human.""" -- cgit v1.2.3 From ed4ebbf5f7ee751f87554831e277d270cf36ac40 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 14 Aug 2020 21:19:04 +0100 Subject: Update tests for user commands --- tests/bot/cogs/test_information.py | 87 ++++++++++++++++++++++++-------------- 1 file changed, 55 insertions(+), 32 deletions(-) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 79c0e0ad3..77b0ddf17 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -215,10 +215,10 @@ class UserInfractionHelperMethodTests(unittest.TestCase): with self.subTest(method=method, api_response=api_response, expected_lines=expected_lines): self.bot.api_client.get.return_value = api_response - expected_output = "\n".join(default_header + expected_lines) + expected_output = "\n".join(expected_lines) actual_output = asyncio.run(method(self.member)) - self.assertEqual(expected_output, actual_output) + self.assertEqual((default_header, expected_output), actual_output) def test_basic_user_infraction_counts_returns_correct_strings(self): """The method should correctly list both the total and active number of non-hidden infractions.""" @@ -249,7 +249,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): }, ) - header = ["**Infractions**"] + header = "Infractions" self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header) @@ -258,7 +258,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): test_values = ( { "api response": [], - "expected_lines": ["This user has never received an infraction."], + "expected_lines": ["No infractions"], }, # Shows non-hidden inactive infraction as expected { @@ -304,7 +304,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): }, ) - header = ["**Infractions**"] + header = "Infractions" self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header) @@ -313,15 +313,15 @@ class UserInfractionHelperMethodTests(unittest.TestCase): test_values = ( { "api response": [], - "expected_lines": ["This user has never been nominated."], + "expected_lines": ["No nominations"], }, { "api response": [{'active': True}], - "expected_lines": ["This user is **currently** nominated (1 nomination in total)."], + "expected_lines": ["This user is **currently** nominated", "(1 nomination in total)"], }, { "api response": [{'active': True}, {'active': False}], - "expected_lines": ["This user is **currently** nominated (2 nominations in total)."], + "expected_lines": ["This user is **currently** nominated", "(2 nominations in total)"], }, { "api response": [{'active': False}], @@ -334,7 +334,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): ) - header = ["**Nominations**"] + header = "Nominations" self._method_subtests(self.cog.user_nomination_counts, test_values, header) @@ -350,7 +350,10 @@ class UserEmbedTests(unittest.TestCase): self.bot.api_client.get = unittest.mock.AsyncMock() self.cog = information.Information(self.bot) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + @unittest.mock.patch( + f"{COG_PATH}.basic_user_infraction_counts", + new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) + ) def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self): """The embed should use the string representation of the user if they don't have a nick.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -362,7 +365,10 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.title, "Mr. Hemlock") - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + @unittest.mock.patch( + f"{COG_PATH}.basic_user_infraction_counts", + new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) + ) def test_create_user_embed_uses_nick_in_title_if_available(self): """The embed should use the nick if it's available.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -374,7 +380,10 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + @unittest.mock.patch( + f"{COG_PATH}.basic_user_infraction_counts", + new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) + ) def test_create_user_embed_ignores_everyone_role(self): """Created `!user` embeds should not contain mention of the @everyone-role.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -386,8 +395,8 @@ class UserEmbedTests(unittest.TestCase): embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - self.assertIn("&Admins", embed.description) - self.assertNotIn("&Everyone", embed.description) + self.assertIn("&Admins", embed.fields[1].value) + self.assertNotIn("&Everyone", embed.fields[1].value) @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=unittest.mock.AsyncMock) @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=unittest.mock.AsyncMock) @@ -398,8 +407,8 @@ class UserEmbedTests(unittest.TestCase): moderators_role = helpers.MockRole(name='Moderators') moderators_role.colour = 100 - infraction_counts.return_value = "expanded infractions info" - nomination_counts.return_value = "nomination info" + infraction_counts.return_value = ("Infractions", "expanded infractions info") + nomination_counts.return_value = ("Nominations", "nomination info") user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) embed = asyncio.run(self.cog.create_user_embed(ctx, user)) @@ -409,20 +418,19 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual( textwrap.dedent(f""" - **User Information** Created: {"1 year ago"} Profile: {user.mention} ID: {user.id} + """).strip(), + embed.fields[0].value + ) - **Member Information** + self.assertEqual( + textwrap.dedent(f""" Joined: {"1 year ago"} Roles: &Moderators - - expanded infractions info - - nomination info """).strip(), - embed.description + embed.fields[1].value ) @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=unittest.mock.AsyncMock) @@ -433,7 +441,7 @@ class UserEmbedTests(unittest.TestCase): moderators_role = helpers.MockRole(name='Moderators') moderators_role.colour = 100 - infraction_counts.return_value = "basic infractions info" + infraction_counts.return_value = ("Infractions", "basic infractions info") user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) embed = asyncio.run(self.cog.create_user_embed(ctx, user)) @@ -442,21 +450,30 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual( textwrap.dedent(f""" - **User Information** Created: {"1 year ago"} Profile: {user.mention} ID: {user.id} + """).strip(), + embed.fields[0].value + ) - **Member Information** + self.assertEqual( + textwrap.dedent(f""" Joined: {"1 year ago"} Roles: &Moderators - - basic infractions info """).strip(), - embed.description + embed.fields[1].value + ) + + self.assertEqual( + "basic infractions info", + embed.fields[3].value ) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + @unittest.mock.patch( + f"{COG_PATH}.basic_user_infraction_counts", + new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) + ) def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self): """The embed should be created with the colour of the top role, if a top role is available.""" ctx = helpers.MockContext() @@ -469,7 +486,10 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + @unittest.mock.patch( + f"{COG_PATH}.basic_user_infraction_counts", + new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) + ) def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self): """The embed should be created with a blurple colour if the user has no assigned roles.""" ctx = helpers.MockContext() @@ -479,7 +499,10 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.colour, discord.Colour.blurple()) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + @unittest.mock.patch( + f"{COG_PATH}.basic_user_infraction_counts", + new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) + ) def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self): """The embed thumbnail should be set to the user's avatar in `png` format.""" ctx = helpers.MockContext() -- cgit v1.2.3 From fd403522896eeb5ffdf10eb5fa1dd0616df32486 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 14 Aug 2020 21:52:57 +0100 Subject: Add status information to user command --- bot/cogs/information.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 34a85a86b..8c5806898 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -6,7 +6,7 @@ from collections import Counter, defaultdict from string import Template from typing import Any, Mapping, Optional, Tuple, Union -from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils +from discord import ChannelType, Colour, CustomActivity, Embed, Guild, Member, Message, Role, Status, utils from discord.abc import GuildChannel from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group from discord.utils import escape_markdown @@ -223,13 +223,18 @@ class Information(Cog): # Custom status custom_status = '' for activity in user.activities: - # Check activity.state for None value if user has a custom status set - # This guards against a custom status with an emoji but no text, which will cause - # escape_markdown to raise an exception - # This can be reworked after a move to d.py 1.3.0+, which adds a CustomActivity class - if activity.name == 'Custom Status' and activity.state: - state = escape_markdown(activity.state) - custom_status = f'Status: {state}\n' + if isinstance(activity, CustomActivity): + state = "" + + if activity.name: + state = escape_markdown(activity.name) + + emoji = "" + if activity.emoji: + if not activity.emoji.id: + emoji += activity.emoji.name + " " + + custom_status = f'Status: {emoji}{state}\n' name = str(user) if user.nick: -- cgit v1.2.3 From b7e40706aa152228154ce96f5aa346a9f5fc43db Mon Sep 17 00:00:00 2001 From: AtieP Date: Sat, 15 Aug 2020 10:22:21 +0200 Subject: Add doc cleanup --- bot/cogs/doc.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 204cffb37..63dcc2c15 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -19,7 +19,7 @@ from sphinx.ext import intersphinx from urllib3.exceptions import ProtocolError from bot.bot import Bot -from bot.constants import MODERATION_ROLES, RedirectOutput +from bot.constants import MODERATION_ROLES, RedirectOutput, Emojis from bot.converters import ValidPythonIdentifier, ValidURL from bot.decorators import with_role from bot.pagination import LinePaginator @@ -28,6 +28,8 @@ from bot.pagination import LinePaginator log = logging.getLogger(__name__) logging.getLogger('urllib3').setLevel(logging.WARNING) +DELETE_EMOJI = Emojis.trashcan + # Since Intersphinx is intended to be used with Sphinx, # we need to mock its configuration. SPHINX_MOCK_APP = SimpleNamespace( @@ -66,6 +68,27 @@ FAILED_REQUEST_RETRY_AMOUNT = 3 NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay +async def doc_cleanup(bot: Bot, author: discord.Member, message: discord.Message) -> None: + """ + Runs the cleanup for the documentation command. + + Adds a :trashcan: reaction what, when clicked, will delete the documentation embed. + After a 300 second timeout, the reaction will be removed.""" + + await message.add_reaction(DELETE_EMOJI) + + def check(reaction: discord.Reaction, member: discord.Member) -> bool: + """Check the reaction is :trashcan:, the author is original author and messages are the same.""" + return str(reaction) == DELETE_EMOJI and member.id == author.id and reaction.message.id == message.id + + with suppress(NotFound): + try: + await bot.wait_for("reaction_add", check=check, timeout=300) + await message.delete() + except asyncio.TimeoutError: + await message.remove_reaction(DELETE_EMOJI, bot.user) + + def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable: """ LRU cache implementation for coroutines. @@ -391,7 +414,8 @@ class Doc(commands.Cog): await error_message.delete(delay=NOT_FOUND_DELETE_DELAY) await ctx.message.delete(delay=NOT_FOUND_DELETE_DELAY) else: - await ctx.send(embed=doc_embed) + doc_embed = await ctx.send(embed=doc_embed) + await doc_cleanup(self.bot, ctx.author, doc_embed) @docs_group.command(name='set', aliases=('s',)) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From 9745f6bdc5d9928cf1cc5d19e3b25da4574d52ec Mon Sep 17 00:00:00 2001 From: AtieP Date: Sat, 15 Aug 2020 10:55:02 +0200 Subject: Satisfy some of the Azure pipelines' code requirements --- bot/cogs/doc.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 63dcc2c15..12ed89004 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -19,7 +19,7 @@ from sphinx.ext import intersphinx from urllib3.exceptions import ProtocolError from bot.bot import Bot -from bot.constants import MODERATION_ROLES, RedirectOutput, Emojis +from bot.constants import Emojis, MODERATION_ROLES, RedirectOutput from bot.converters import ValidPythonIdentifier, ValidURL from bot.decorators import with_role from bot.pagination import LinePaginator @@ -73,8 +73,8 @@ async def doc_cleanup(bot: Bot, author: discord.Member, message: discord.Message Runs the cleanup for the documentation command. Adds a :trashcan: reaction what, when clicked, will delete the documentation embed. - After a 300 second timeout, the reaction will be removed.""" - + After a 300 second timeout, the reaction will be removed. + """ await message.add_reaction(DELETE_EMOJI) def check(reaction: discord.Reaction, member: discord.Member) -> bool: -- cgit v1.2.3 From 7cd29c72c0c074680d63740b79b388da95a50de5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 09:55:43 -0700 Subject: Don't patch ctx.message.author in antispam The modification propagated across all code that is using the same `Message` object, including all other `on_message` listeners. This caused weird bugs e.g. the filtering cog thinking the bot authored a message that triggered a filter. Patching only `ctx.author` means the implementation is more fragile. Infraction code must ensure it only retrieves the author via `ctx.author` and not through `ctx.message`. Fixes #1005 Fixes BOT-7D --- bot/cogs/antispam.py | 1 - bot/cogs/moderation/scheduler.py | 6 ++++-- bot/cogs/moderation/utils.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 0bcca578d..bc31cbd95 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -219,7 +219,6 @@ class AntiSpam(Cog): # Get context and make sure the bot becomes the actor of infraction by patching the `author` attributes context = await self.bot.get_context(msg) context.author = self.bot.user - context.message.author = self.bot.user # Since we're going to invoke the tempmute command directly, we need to manually call the converter. dt_remove_role_after = await self.expiration_date_converter.convert(context, f"{remove_role_after}S") diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 75028d851..051f6c52c 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -161,6 +161,7 @@ class InfractionScheduler: self.schedule_expiration(infraction) except discord.HTTPException as e: # Accordingly display that applying the infraction failed. + # Don't use ctx.message.author; antispam only patches ctx.author. confirm_msg = ":x: failed to apply" expiry_msg = "" log_content = ctx.author.mention @@ -190,6 +191,7 @@ class InfractionScheduler: await ctx.send(f"{dm_result}{confirm_msg}{infr_message}.") # Send a log message to the mod log. + # Don't use ctx.message.author for the actor; antispam only patches ctx.author. log.trace(f"Sending apply mod log for infraction #{id_}.") await self.mod_log.send_log_message( icon_url=icon, @@ -198,7 +200,7 @@ class InfractionScheduler: thumbnail=user.avatar_url_as(static_format="png"), text=textwrap.dedent(f""" Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author}{dm_log_text}{expiry_log_text} + Actor: {ctx.author}{dm_log_text}{expiry_log_text} Reason: {reason} """), content=log_content, @@ -242,7 +244,7 @@ class InfractionScheduler: log_text = await self.deactivate_infraction(response[0], send_log=False) log_text["Member"] = f"{user.mention}(`{user.id}`)" - log_text["Actor"] = str(ctx.message.author) + log_text["Actor"] = str(ctx.author) log_content = None id_ = response[0]['id'] footer = f"ID: {id_}" diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index fb55287b6..f21272102 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -70,7 +70,7 @@ async def post_infraction( log.trace(f"Posting {infr_type} infraction for {user} to the API.") payload = { - "actor": ctx.message.author.id, + "actor": ctx.author.id, # Don't use ctx.message.author; antispam only patches ctx.author. "hidden": hidden, "reason": reason, "type": infr_type, -- cgit v1.2.3 From f26deafbebf1d3f6790a165d403e0fb664117939 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 12:23:26 -0700 Subject: Truncate mod log content Discord has a limit of 2000 characters for messages. --- bot/cogs/moderation/modlog.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 0a63f57b8..5f30d3744 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -120,6 +120,10 @@ class ModLog(Cog, name="ModLog"): else: content = "@everyone" + # Truncate content to 2000 characters and append an ellipsis. + if content and len(content) > 2000: + content = content[:2000 - 3] + "..." + channel = self.bot.get_channel(channel_id) log_message = await channel.send( content=content, -- cgit v1.2.3 From 056936eafc927e8770acdc6f70bf2971cca4f4d2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 12:49:26 -0700 Subject: Escape Markdown in reddit post titles Use a Unicode look-alike character to replace square brackets, since they'd otherwise interfere with the Markdown. Fixes #1030 --- bot/cogs/reddit.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index d853ab2ea..5d9e2c20b 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -10,6 +10,7 @@ from aiohttp import BasicAuth, ClientError from discord import Colour, Embed, TextChannel from discord.ext.commands import Cog, Context, group from discord.ext.tasks import loop +from discord.utils import escape_markdown from bot.bot import Bot from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES, Webhooks @@ -187,6 +188,8 @@ class Reddit(Cog): author = data["author"] title = textwrap.shorten(data["title"], width=64, placeholder="...") + # Normal brackets interfere with Markdown. + title = escape_markdown(title).replace("[", "⦋").replace("]", "⦌") link = self.URL + data["permalink"] embed.description += ( -- cgit v1.2.3 From 063ae2baa0be2d698705dbf896a4e14511416788 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 13:21:02 -0700 Subject: Unnominate banned users from the talent pool Fixes #1065 --- bot/cogs/watchchannels/talentpool.py | 54 +++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 89256e92e..002f01399 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -1,8 +1,9 @@ import logging import textwrap from collections import ChainMap +from typing import Union -from discord import Color, Embed, Member +from discord import Color, Embed, Member, User from discord.ext.commands import Cog, Context, group from bot.api import ResponseCodeError @@ -164,25 +165,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): Providing a `reason` is required. """ - active_nomination = await self.bot.api_client.get( - self.api_endpoint, - params=ChainMap( - self.api_default_params, - {"user__id": str(user.id)} - ) - ) - - if not active_nomination: + if await self.unwatch(user.id, reason): + await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") + else: await ctx.send(":x: The specified user does not have an active nomination") - return - - [nomination] = active_nomination - await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination['id']}", - json={'end_reason': reason, 'active': False} - ) - await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") - self._remove_user(user.id) @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True) @with_role(*MODERATION_ROLES) @@ -220,6 +206,36 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): await ctx.send(f":white_check_mark: Updated the {field} of the nomination!") + @Cog.listener() + async def on_member_ban(self, guild: Guild, user: Union[User, Member]) -> None: + """Remove `user` from the talent pool after they are banned.""" + await self.unwatch(user.id, "User was banned.") + + async def unwatch(self, user_id: int, reason: str) -> bool: + """End the active nomination of a user with the given reason and return True on success.""" + active_nomination = await self.bot.api_client.get( + self.api_endpoint, + params=ChainMap( + self.api_default_params, + {"user__id": str(user_id)} + ) + ) + + if not active_nomination: + log.debug(f"No active nominate exists for {user_id=}") + return False + + log.info(f"Ending nomination: {user_id=} {reason=}") + + [nomination] = active_nomination + await self.bot.api_client.patch( + f"{self.api_endpoint}/{nomination['id']}", + json={'end_reason': reason, 'active': False} + ) + self._remove_user(user_id) + + return True + def _nomination_to_string(self, nomination_object: dict) -> str: """Creates a string representation of a nomination.""" guild = self.bot.get_guild(Guild.id) -- cgit v1.2.3 From 4df3089d8d03f54cdbd14d7683149ae7931036c1 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 16 Aug 2020 15:28:23 +0200 Subject: Remove the !ask tag --- bot/resources/tags/ask.md | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 bot/resources/tags/ask.md diff --git a/bot/resources/tags/ask.md b/bot/resources/tags/ask.md deleted file mode 100644 index e2c2a88f6..000000000 --- a/bot/resources/tags/ask.md +++ /dev/null @@ -1,9 +0,0 @@ -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/). -- cgit v1.2.3 From 0a865004d7e33a6d379f04b121cf3201411c75a3 Mon Sep 17 00:00:00 2001 From: AtieP Date: Sun, 16 Aug 2020 17:43:34 +0200 Subject: Use wait_for_deletion from /bot/utils/messages.py instead of doc_cleanup --- bot/cogs/doc.py | 28 +++------------------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 12ed89004..a3b1d26a1 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -19,17 +19,16 @@ from sphinx.ext import intersphinx from urllib3.exceptions import ProtocolError from bot.bot import Bot -from bot.constants import Emojis, MODERATION_ROLES, RedirectOutput +from bot.constants import MODERATION_ROLES, RedirectOutput from bot.converters import ValidPythonIdentifier, ValidURL from bot.decorators import with_role from bot.pagination import LinePaginator +from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) logging.getLogger('urllib3').setLevel(logging.WARNING) -DELETE_EMOJI = Emojis.trashcan - # Since Intersphinx is intended to be used with Sphinx, # we need to mock its configuration. SPHINX_MOCK_APP = SimpleNamespace( @@ -68,27 +67,6 @@ FAILED_REQUEST_RETRY_AMOUNT = 3 NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay -async def doc_cleanup(bot: Bot, author: discord.Member, message: discord.Message) -> None: - """ - Runs the cleanup for the documentation command. - - Adds a :trashcan: reaction what, when clicked, will delete the documentation embed. - After a 300 second timeout, the reaction will be removed. - """ - await message.add_reaction(DELETE_EMOJI) - - def check(reaction: discord.Reaction, member: discord.Member) -> bool: - """Check the reaction is :trashcan:, the author is original author and messages are the same.""" - return str(reaction) == DELETE_EMOJI and member.id == author.id and reaction.message.id == message.id - - with suppress(NotFound): - try: - await bot.wait_for("reaction_add", check=check, timeout=300) - await message.delete() - except asyncio.TimeoutError: - await message.remove_reaction(DELETE_EMOJI, bot.user) - - def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable: """ LRU cache implementation for coroutines. @@ -415,7 +393,7 @@ class Doc(commands.Cog): await ctx.message.delete(delay=NOT_FOUND_DELETE_DELAY) else: doc_embed = await ctx.send(embed=doc_embed) - await doc_cleanup(self.bot, ctx.author, doc_embed) + await wait_for_deletion(doc_embed, (ctx.author.id,), client=self.bot) @docs_group.command(name='set', aliases=('s',)) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From 92094a5f9d4b8cb9693f5e7bd77e69384f25946e Mon Sep 17 00:00:00 2001 From: AtieP Date: Sun, 16 Aug 2020 19:24:27 +0200 Subject: msg rather than doc_embed --- bot/cogs/doc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index a3b1d26a1..30c793c75 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -392,8 +392,8 @@ class Doc(commands.Cog): await error_message.delete(delay=NOT_FOUND_DELETE_DELAY) await ctx.message.delete(delay=NOT_FOUND_DELETE_DELAY) else: - doc_embed = await ctx.send(embed=doc_embed) - await wait_for_deletion(doc_embed, (ctx.author.id,), client=self.bot) + msg = await ctx.send(embed=doc_embed) + await wait_for_deletion(msg, (ctx.author.id,), client=self.bot) @docs_group.command(name='set', aliases=('s',)) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From e8bd4f6d2316f351ad2c11b0b4db160939ab6ed5 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 16 Aug 2020 20:59:23 +0100 Subject: Re-align status icons --- bot/cogs/information.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 8c5806898..776a0d474 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -273,9 +273,9 @@ class Information(Cog): ( "Status", textwrap.dedent(f""" - Desktop: {desktop_status} - Web: {web_status} - Mobile: {mobile_status} + {desktop_status} Desktop + {web_status} Web + {mobile_status} Mobile """).strip() ) ] -- cgit v1.2.3 From ff91b76348d95e308589ac131898aba9f7cca986 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 19 Aug 2020 20:06:23 +0200 Subject: Verification: add missing word to task status message --- bot/cogs/verification.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 14c0abfda..08d54d575 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -537,9 +537,9 @@ class Verification(Cog): mention = f"<@&{constants.Roles.unverified}>" if self.ping_unverified.is_running(): - ping_status = f"{constants.Emojis.incident_actioned} Ping {mention} is running." + ping_status = f"{constants.Emojis.incident_actioned} Ping {mention} task is running." else: - ping_status = f"{constants.Emojis.incident_unactioned} Ping {mention} is **not** running." + ping_status = f"{constants.Emojis.incident_unactioned} Ping {mention} task is **not** running." embed = discord.Embed( title="Verification system", -- cgit v1.2.3 From 743a6b434000813425bf6480b9f7788043f6115d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 19 Aug 2020 13:46:17 -0700 Subject: Swap argument order in ChainMaps The defaults should be last to ensure they don't take precedence over explicitly set values. --- bot/cogs/watchchannels/bigbrother.py | 2 +- bot/cogs/watchchannels/talentpool.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 4d27a6333..7aa9cec58 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -131,8 +131,8 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): active_watches = await self.bot.api_client.get( self.api_endpoint, params=ChainMap( + {"user__id": str(user.id)}, self.api_default_params, - {"user__id": str(user.id)} ) ) if active_watches: diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 002f01399..c5621ae18 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -216,8 +216,8 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): active_nomination = await self.bot.api_client.get( self.api_endpoint, params=ChainMap( + {"user__id": str(user_id)}, self.api_default_params, - {"user__id": str(user_id)} ) ) -- cgit v1.2.3 From bca71687eec90b88e60155679d369b57344a0ddc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 19 Aug 2020 13:50:21 -0700 Subject: Replace stinky single-item unpacking syntax --- bot/cogs/watchchannels/talentpool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index c5621ae18..a6df84c23 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -227,7 +227,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): log.info(f"Ending nomination: {user_id=} {reason=}") - [nomination] = active_nomination + nomination = active_nomination[0] await self.bot.api_client.patch( f"{self.api_endpoint}/{nomination['id']}", json={'end_reason': reason, 'active': False} -- cgit v1.2.3 From 574bcac2b3fb43fc74a6c840667cfed408bc4077 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 20 Aug 2020 13:53:54 +0200 Subject: Restrict reminder methods to authors and admins. Before, any user could modify the reminders of others by the id. This restricts the behaviour to only admins and users can only modify the reminders they authored. --- bot/cogs/reminders.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 670493bcf..08bce2153 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -12,10 +12,10 @@ from dateutil.relativedelta import relativedelta from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot -from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES +from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_ROLES from bot.converters import Duration from bot.pagination import LinePaginator -from bot.utils.checks import without_role_check +from bot.utils.checks import with_role_check, without_role_check from bot.utils.messages import send_denial from bot.utils.scheduling import Scheduler from bot.utils.time import humanize_delta @@ -396,6 +396,8 @@ class Reminders(Cog): async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: """Edits a reminder with the given payload, then sends a confirmation message.""" + if not await self._can_modify(ctx, id_): + return reminder = await self._edit_reminder(id_, payload) # Parse the reminder expiration back into a datetime @@ -413,6 +415,8 @@ class Reminders(Cog): @remind_group.command("delete", aliases=("remove", "cancel")) async def delete_reminder(self, ctx: Context, id_: int) -> None: """Delete one of your active reminders.""" + if not await self._can_modify(ctx, id_): + return await self._delete_reminder(id_) await self._send_confirmation( ctx, @@ -421,6 +425,24 @@ class Reminders(Cog): delivery_dt=None, ) + async def _can_modify(self, ctx: Context, reminder_id: t.Union[str, int]) -> bool: + """ + Check whether the reminder can be modified by the ctx author. + + The check passes when the user is an admin, or if they created the reminder. + """ + if with_role_check(ctx, Roles.admins): + return True + + api_response = await self.bot.api_client.get(f"bot/reminders/{reminder_id}") + if not api_response["author"] == ctx.author.id: + log.debug(f"{ctx.author} is not the reminder author and does not pass the check.") + await send_denial(ctx, "You can't modify reminders of other users!") + return False + + log.debug(f"{ctx.author} is the reminder author and passes the check.") + return True + def setup(bot: Bot) -> None: """Load the Reminders cog.""" -- cgit v1.2.3 From 47521608d573c97597df7b97bf42b0142f79e98c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 20 Aug 2020 11:02:40 -0700 Subject: Make client parameter mandatory for wait_for_deletion A client instance is necessary for the core feature of this function. There is no way to obtain it from the other arguments. The previous code was wrong to think `discord.Guild.me` is an equivalent. Fixes #1112 --- bot/cogs/bot.py | 2 +- bot/cogs/snekbox.py | 4 +--- bot/cogs/tags.py | 4 ++-- bot/utils/messages.py | 13 ++++--------- 4 files changed, 8 insertions(+), 15 deletions(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 79510739c..70ef407d7 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -337,7 +337,7 @@ class BotCog(Cog, name="Bot"): self.codeblock_message_ids[msg.id] = bot_message.id self.bot.loop.create_task( - wait_for_deletion(bot_message, user_ids=(msg.author.id,), client=self.bot) + wait_for_deletion(bot_message, (msg.author.id,), self.bot) ) else: return diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 52c8b6f88..63e6d7f31 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -220,9 +220,7 @@ class Snekbox(Cog): response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.") else: response = await ctx.send(msg) - self.bot.loop.create_task( - wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) - ) + self.bot.loop.create_task(wait_for_deletion(response, (ctx.author.id,), ctx.bot)) log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") return response diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 3d76c5c08..d01647312 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -236,7 +236,7 @@ class Tags(Cog): await wait_for_deletion( await ctx.send(embed=Embed.from_dict(tag['embed'])), [ctx.author.id], - client=self.bot + self.bot ) elif founds and len(tag_name) >= 3: await wait_for_deletion( @@ -247,7 +247,7 @@ class Tags(Cog): ) ), [ctx.author.id], - client=self.bot + self.bot ) else: diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 670289941..aa8f17f75 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -19,25 +19,20 @@ log = logging.getLogger(__name__) async def wait_for_deletion( message: Message, user_ids: Sequence[Snowflake], + client: Client, deletion_emojis: Sequence[str] = (Emojis.trashcan,), timeout: float = 60 * 5, attach_emojis: bool = True, - client: Optional[Client] = None ) -> None: """ Wait for up to `timeout` seconds for a reaction by any of the specified `user_ids` to delete the message. An `attach_emojis` bool may be specified to determine whether to attach the given - `deletion_emojis` to the message in the given `context` - - A `client` instance may be optionally specified, otherwise client will be taken from the - guild of the message. + `deletion_emojis` to the message in the given `context`. """ - if message.guild is None and client is None: + if message.guild is None: raise ValueError("Message must be sent on a guild") - bot = client or message.guild.me - if attach_emojis: for emoji in deletion_emojis: await message.add_reaction(emoji) @@ -51,7 +46,7 @@ async def wait_for_deletion( ) with contextlib.suppress(asyncio.TimeoutError): - await bot.wait_for('reaction_add', check=check, timeout=timeout) + await client.wait_for('reaction_add', check=check, timeout=timeout) await message.delete() -- cgit v1.2.3 From e0438b2f78ffbc22a9d4d391db524563ec9baa18 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 20 Aug 2020 11:16:18 -0700 Subject: Watchchannels: censor message content if it has a leaked token Fixes #1094 --- bot/cogs/watchchannels/watchchannel.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 044077350..a58b604c0 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -15,6 +15,8 @@ from discord.ext.commands import Cog, Context from bot.api import ResponseCodeError from bot.bot import Bot from bot.cogs.moderation import ModLog +from bot.cogs.token_remover import TokenRemover +from bot.cogs.webhook_remover import WEBHOOK_URL_RE from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons from bot.pagination import LinePaginator from bot.utils import CogABCMeta, messages @@ -226,14 +228,16 @@ class WatchChannel(metaclass=CogABCMeta): await self.send_header(msg) - cleaned_content = msg.clean_content - - if cleaned_content: + if TokenRemover.find_token_in_message(msg) or WEBHOOK_URL_RE.search(msg.content): + cleaned_content = "Content is censored because it contains a bot or webhook token." + elif cleaned_content := msg.clean_content: # Put all non-media URLs in a code block to prevent embeds media_urls = {embed.url for embed in msg.embeds if embed.type in ("image", "video")} for url in URL_RE.findall(cleaned_content): if url not in media_urls: cleaned_content = cleaned_content.replace(url, f"`{url}`") + + if cleaned_content: await self.webhook_send( cleaned_content, username=msg.author.display_name, -- cgit v1.2.3 From c0afea19897ec0b47642bb62e4a426f4ca0c3cc8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 20 Aug 2020 11:18:02 -0700 Subject: Don't send code block help if message has a webhook token --- bot/cogs/bot.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 79510739c..93f2eae7c 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -9,6 +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.cogs.webhook_remover import WEBHOOK_URL_RE 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 @@ -240,6 +241,7 @@ class BotCog(Cog, name="Bot"): and not msg.author.bot and len(msg.content.splitlines()) > 3 and not TokenRemover.find_token_in_message(msg) + and not WEBHOOK_URL_RE.search(msg.content) ) if parse_codeblock: # no token in the msg -- cgit v1.2.3 From 36ccac8272de9e60c1c04db7ab3640fd76af8585 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Thu, 20 Aug 2020 22:35:12 +0100 Subject: Disable raw commands --- bot/cogs/information.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 8982196d1..2d87866fb 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -376,7 +376,7 @@ class Information(Cog): return out.rstrip() @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) - @group(invoke_without_command=True) + @group(invoke_without_command=True, enabled=False) @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.""" @@ -411,7 +411,7 @@ class Information(Cog): for page in paginator.pages: await ctx.send(page) - @raw.command() + @raw.command(enabled=False) async def json(self, ctx: Context, message: Message) -> None: """Shows information about the raw API response in a copy-pasteable Python format.""" await ctx.invoke(self.raw, message=message, json=True) -- cgit v1.2.3 From 59a58db3ca6ba14539b028f3e02ccc4d89ec16a0 Mon Sep 17 00:00:00 2001 From: AtieP Date: Sat, 22 Aug 2020 18:38:05 +0200 Subject: Use wait_for_deletion from bot/utils/messages.py rather than help_cleanup --- bot/cogs/help.py | 31 +++++-------------------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 3d1d6fd10..76aaf655c 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -1,11 +1,10 @@ import itertools import logging -from asyncio import TimeoutError from collections import namedtuple from contextlib import suppress from typing import List, Union -from discord import Colour, Embed, Member, Message, NotFound, Reaction, User +from discord import Colour, Embed from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand from fuzzywuzzy import fuzz, process from fuzzywuzzy.utils import full_process @@ -14,6 +13,7 @@ from bot import constants from bot.constants import Channels, Emojis, STAFF_ROLES from bot.decorators import redirect_output from bot.pagination import LinePaginator +from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) @@ -24,27 +24,6 @@ PREFIX = constants.Bot.prefix Category = namedtuple("Category", ["name", "description", "cogs"]) -async def help_cleanup(bot: Bot, author: Member, message: Message) -> None: - """ - Runs the cleanup for the help command. - - Adds the :trashcan: reaction that, when clicked, will delete the help message. - After a 300 second timeout, the reaction will be removed. - """ - def check(reaction: Reaction, user: User) -> bool: - """Checks the reaction is :trashcan:, the author is original author and messages are the same.""" - return str(reaction) == DELETE_EMOJI and user.id == author.id and reaction.message.id == message.id - - await message.add_reaction(DELETE_EMOJI) - - with suppress(NotFound): - try: - await bot.wait_for("reaction_add", check=check, timeout=300) - await message.delete() - except TimeoutError: - await message.remove_reaction(DELETE_EMOJI, bot.user) - - class HelpQueryNotFound(ValueError): """ Raised when a HelpSession Query doesn't match a command or cog. @@ -206,7 +185,7 @@ class CustomHelpCommand(HelpCommand): """Send help for a single command.""" embed = await self.command_formatting(command) message = await self.context.send(embed=embed) - await help_cleanup(self.context.bot, self.context.author, message) + await wait_for_deletion(message, (self.context.author.id,), self.context.bot) @staticmethod def get_commands_brief_details(commands_: List[Command], return_as_list: bool = False) -> Union[List[str], str]: @@ -245,7 +224,7 @@ class CustomHelpCommand(HelpCommand): embed.description += f"\n**Subcommands:**\n{command_details}" message = await self.context.send(embed=embed) - await help_cleanup(self.context.bot, self.context.author, message) + await wait_for_deletion(message, (self.context.author.id,), self.context.bot) async def send_cog_help(self, cog: Cog) -> None: """Send help for a cog.""" @@ -261,7 +240,7 @@ class CustomHelpCommand(HelpCommand): embed.description += f"\n\n**Commands:**\n{command_details}" message = await self.context.send(embed=embed) - await help_cleanup(self.context.bot, self.context.author, message) + await wait_for_deletion(message, (self.context.author.id,), self.context.bot) @staticmethod def _category_key(command: Command) -> str: -- cgit v1.2.3 From ee4efbb91300890424d1f8ecb1273166e9f0f53a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 13:05:49 -0700 Subject: Define a Command subclass with root alias support A subclass is used because cogs make copies of Command objects. They do this to allow multiple instances of a cog to be used. If the Command class doesn't inherently support the `root_aliases` kwarg, it won't end up being copied when a command gets copied. `Command.__original_kwargs__` could be updated to include the new kwarg. However, updating it and adding the attribute to the command wouldn't be as elegant as passing a `Command` subclass as a `cls` attribute to the `commands.command` decorator. This is because the former requires copying the entire code of the decorator to add the two lines into the nested function (it's a decorator with args, hence the nested function). --- bot/command.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 bot/command.py diff --git a/bot/command.py b/bot/command.py new file mode 100644 index 000000000..92e61d97e --- /dev/null +++ b/bot/command.py @@ -0,0 +1,15 @@ +from discord.ext import commands + + +class Command(commands.Command): + """ + A `discord.ext.commands.Command` subclass which supports root aliases. + + A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as + top-level commands rather than being aliases of the command's group. It's stored as an attribute + also named `root_aliases`. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.root_aliases = kwargs.get("root_aliases", []) -- cgit v1.2.3 From f455a7908a9b07747db6ab89f9c5c53bd5ea2450 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 19:13:21 -0700 Subject: Bot: add root alias support Override `Bot.add_command` and `Bot.remove_command` to add/remove root aliases for a command (and recursively for any subcommands). This has to happen in `Bot` because there's no reliable way to get the `Bot` instance otherwise. Therefore, overriding the methods in `GroupMixin` unfortunately doesn't work. Otherwise, it'd be possible to avoid recursion by processing each subcommand as it got added. --- bot/bot.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/bot/bot.py b/bot/bot.py index 756449293..34254d8e8 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -130,6 +130,26 @@ class Bot(commands.Bot): super().add_cog(cog) log.info(f"Cog loaded: {cog.qualified_name}") + def add_command(self, command: commands.Command) -> None: + """Add `command` as normal and then add its root aliases to the bot.""" + super().add_command(command) + self._add_root_aliases(command) + + def remove_command(self, name: str) -> Optional[commands.Command]: + """ + Remove a command/alias as normal and then remove its root aliases from the bot. + + Individual root aliases cannot be removed by this function. + To remove them, either remove the entire command or manually edit `bot.all_commands`. + """ + command = super().remove_command(name) + if command is None: + # Even if it's a root alias, there's no way to get the Bot instance to remove the alias. + return + + self._remove_root_aliases(command) + return command + def clear(self) -> None: """ Clears the internal state of the bot and recreates the connector and sessions. @@ -235,3 +255,24 @@ class Bot(commands.Bot): scope.set_extra("kwargs", kwargs) log.exception(f"Unhandled exception in {event}.") + + def _add_root_aliases(self, command: commands.Command) -> None: + """Recursively add root aliases for `command` and any of its subcommands.""" + if isinstance(command, commands.Group): + for subcommand in command.commands: + self._add_root_aliases(subcommand) + + for alias in command.root_aliases: + if alias in self.all_commands: + raise commands.CommandRegistrationError(alias, alias_conflict=True) + + self.all_commands[alias] = command + + def _remove_root_aliases(self, command: commands.Command) -> None: + """Recursively remove root aliases for `command` and any of its subcommands.""" + if isinstance(command, commands.Group): + for subcommand in command.commands: + self._remove_root_aliases(subcommand) + + for alias in command.root_aliases: + self.all_commands.pop(alias, None) -- cgit v1.2.3 From 36ec4b31730ac7243fc76fe5140a0ed2e922940f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 19:20:54 -0700 Subject: Patch d.py decorators to support root aliases To avoid explicitly specifying `cls` everywhere, patch the decorators to set the default value of `cls` to the `Command` subclass which supports root aliases. --- bot/__init__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bot/__init__.py b/bot/__init__.py index d63086fe2..3ee70c4e9 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -2,10 +2,14 @@ import asyncio import logging import os import sys +from functools import partial, partialmethod from logging import Logger, handlers from pathlib import Path import coloredlogs +from discord.ext import commands + +from bot.command import Command TRACE_LEVEL = logging.TRACE = 5 logging.addLevelName(TRACE_LEVEL, "TRACE") @@ -66,3 +70,9 @@ logging.getLogger(__name__) # On Windows, the selector event loop is required for aiodns. if os.name == "nt": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + +# Monkey-patch discord.py decorators to use the Command subclass which supports root aliases. +# Must be patched before any cogs are added. +commands.command = partial(commands.command, cls=Command) +commands.GroupMixin.command = partialmethod(commands.GroupMixin.command, cls=Command) -- cgit v1.2.3 From 027ce8c5525187296a9f7bd26b89af9c66200835 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 19:43:15 -0700 Subject: Bot: fix AttributeError for commands which lack root_aliases Even if the `command` decorators are patched, there are still some other internal things that need to be patched. For example, the default help command subclasses the original `Command` type. It's more maintainable to exclude root alias support for these objects than to try to patch everything. --- bot/bot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 34254d8e8..d25074fd9 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -262,7 +262,7 @@ class Bot(commands.Bot): for subcommand in command.commands: self._add_root_aliases(subcommand) - for alias in command.root_aliases: + for alias in getattr(command, "root_aliases", ()): if alias in self.all_commands: raise commands.CommandRegistrationError(alias, alias_conflict=True) @@ -274,5 +274,5 @@ class Bot(commands.Bot): for subcommand in command.commands: self._remove_root_aliases(subcommand) - for alias in command.root_aliases: + for alias in getattr(command, "root_aliases", ()): self.all_commands.pop(alias, None) -- cgit v1.2.3 From c6a20ef3b7b7afe3013a17042f8f2ca84566d998 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 20:36:27 -0700 Subject: Replace alias command definitions with root_aliases The fruits of my labour. --- bot/cogs/alias.py | 70 ++---------------------------------- bot/cogs/defcon.py | 4 +-- bot/cogs/extensions.py | 2 +- bot/cogs/site.py | 10 +++--- bot/cogs/watchchannels/bigbrother.py | 4 +-- bot/cogs/watchchannels/talentpool.py | 6 ++-- 6 files changed, 15 insertions(+), 81 deletions(-) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 55c7efe65..c6ba8d6f3 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -3,13 +3,12 @@ import logging from discord import Colour, Embed from discord.ext.commands import ( - Cog, Command, Context, Greedy, + Cog, Command, Context, clean_content, command, group, ) from bot.bot import Bot -from bot.cogs.extensions import Extension -from bot.converters import FetchedMember, TagNameConverter +from bot.converters import TagNameConverter from bot.pagination import LinePaginator log = logging.getLogger(__name__) @@ -51,56 +50,6 @@ class Alias (Cog): ctx, embed, empty=False, max_lines=20 ) - @command(name="resources", aliases=("resource",), hidden=True) - async def site_resources_alias(self, ctx: Context) -> None: - """Alias for invoking site resources.""" - await self.invoke(ctx, "site resources") - - @command(name="tools", hidden=True) - async def site_tools_alias(self, ctx: Context) -> None: - """Alias for invoking site tools.""" - await self.invoke(ctx, "site tools") - - @command(name="watch", hidden=True) - async def bigbrother_watch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Alias for invoking bigbrother watch [user] [reason].""" - await self.invoke(ctx, "bigbrother watch", user, reason=reason) - - @command(name="unwatch", hidden=True) - async def bigbrother_unwatch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Alias for invoking bigbrother unwatch [user] [reason].""" - await self.invoke(ctx, "bigbrother unwatch", user, reason=reason) - - @command(name="home", hidden=True) - async def site_home_alias(self, ctx: Context) -> None: - """Alias for invoking site home.""" - await self.invoke(ctx, "site home") - - @command(name="faq", hidden=True) - async def site_faq_alias(self, ctx: Context) -> None: - """Alias for invoking site faq.""" - await self.invoke(ctx, "site faq") - - @command(name="rules", aliases=("rule",), hidden=True) - async def site_rules_alias(self, ctx: Context, rules: Greedy[int], *_: str) -> None: - """Alias for invoking site rules.""" - await self.invoke(ctx, "site rules", *rules) - - @command(name="reload", hidden=True) - async def extensions_reload_alias(self, ctx: Context, *extensions: Extension) -> None: - """Alias for invoking extensions reload [extensions...].""" - await self.invoke(ctx, "extensions reload", *extensions) - - @command(name="defon", hidden=True) - async def defcon_enable_alias(self, ctx: Context) -> None: - """Alias for invoking defcon enable.""" - await self.invoke(ctx, "defcon enable") - - @command(name="defoff", hidden=True) - async def defcon_disable_alias(self, ctx: Context) -> None: - """Alias for invoking defcon disable.""" - await self.invoke(ctx, "defcon disable") - @command(name="exception", hidden=True) async def tags_get_traceback_alias(self, ctx: Context) -> None: """Alias for invoking tags get traceback.""" @@ -132,21 +81,6 @@ class Alias (Cog): """Alias for invoking docs get [symbol].""" await self.invoke(ctx, "docs get", symbol) - @command(name="nominate", hidden=True) - async def nomination_add_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Alias for invoking talentpool add [user] [reason].""" - await self.invoke(ctx, "talentpool add", user, reason=reason) - - @command(name="unnominate", hidden=True) - async def nomination_end_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Alias for invoking nomination end [user] [reason].""" - await self.invoke(ctx, "nomination end", user, reason=reason) - - @command(name="nominees", hidden=True) - async def nominees_alias(self, ctx: Context) -> None: - """Alias for invoking tp watched.""" - await self.invoke(ctx, "talentpool watched") - def setup(bot: Bot) -> None: """Load the Alias cog.""" diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 4c0ad5914..de0f4545e 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -162,7 +162,7 @@ class Defcon(Cog): self.bot.stats.gauge("defcon.threshold", days) - @defcon_group.command(name='enable', aliases=('on', 'e')) + @defcon_group.command(name='enable', aliases=('on', 'e'), root_aliases=("defon",)) @with_role(Roles.admins, Roles.owners) async def enable_command(self, ctx: Context) -> None: """ @@ -175,7 +175,7 @@ class Defcon(Cog): await self._defcon_action(ctx, days=0, action=Action.ENABLED) await self.update_channel_topic() - @defcon_group.command(name='disable', aliases=('off', 'd')) + @defcon_group.command(name='disable', aliases=('off', 'd'), root_aliases=("defoff",)) @with_role(Roles.admins, Roles.owners) async def disable_command(self, ctx: Context) -> None: """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!""" diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index 365f198ff..396e406b0 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -107,7 +107,7 @@ class Extensions(commands.Cog): await ctx.send(msg) - @extensions_group.command(name="reload", aliases=("r",)) + @extensions_group.command(name="reload", aliases=("r",), root_aliases=("reload",)) async def reload_command(self, ctx: Context, *extensions: Extension) -> None: r""" Reload extensions given their fully qualified or unqualified names. diff --git a/bot/cogs/site.py b/bot/cogs/site.py index ac29daa1d..2d3a3d9f3 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -23,7 +23,7 @@ class Site(Cog): """Commands for getting info about our website.""" await ctx.send_help(ctx.command) - @site_group.command(name="home", aliases=("about",)) + @site_group.command(name="home", aliases=("about",), root_aliases=("home",)) async def site_main(self, ctx: Context) -> None: """Info about the website itself.""" url = f"{URLs.site_schema}{URLs.site}/" @@ -40,7 +40,7 @@ class Site(Cog): await ctx.send(embed=embed) - @site_group.command(name="resources") + @site_group.command(name="resources", root_aliases=("resources", "resource")) async def site_resources(self, ctx: Context) -> None: """Info about the site's Resources page.""" learning_url = f"{PAGES_URL}/resources" @@ -56,7 +56,7 @@ class Site(Cog): await ctx.send(embed=embed) - @site_group.command(name="tools") + @site_group.command(name="tools", root_aliases=("tools",)) async def site_tools(self, ctx: Context) -> None: """Info about the site's Tools page.""" tools_url = f"{PAGES_URL}/resources/tools" @@ -87,7 +87,7 @@ class Site(Cog): await ctx.send(embed=embed) - @site_group.command(name="faq") + @site_group.command(name="faq", root_aliases=("faq",)) async def site_faq(self, ctx: Context) -> None: """Info about the site's FAQ page.""" url = f"{PAGES_URL}/frequently-asked-questions" @@ -104,7 +104,7 @@ class Site(Cog): await ctx.send(embed=embed) - @site_group.command(aliases=['r', 'rule'], name='rules') + @site_group.command(name="rules", aliases=("r", "rule"), root_aliases=("rules", "rule")) async def site_rules(self, ctx: Context, *rules: int) -> None: """Provides a link to all rules or, if specified, displays specific rule(s).""" rules_embed = Embed(title='Rules', color=Colour.blurple()) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 7aa9cec58..11ab8917a 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -59,7 +59,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): """ await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) - @bigbrother_group.command(name='watch', aliases=('w',)) + @bigbrother_group.command(name='watch', aliases=('w',), root_aliases=('watch',)) @with_role(*MODERATION_ROLES) async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """ @@ -70,7 +70,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): """ await self.apply_watch(ctx, user, reason) - @bigbrother_group.command(name='unwatch', aliases=('uw',)) + @bigbrother_group.command(name='unwatch', aliases=('uw',), root_aliases=('unwatch',)) @with_role(*MODERATION_ROLES) async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """Stop relaying messages by the given `user`.""" diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index a6df84c23..76d6fe9bd 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -37,7 +37,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" await ctx.send_help(ctx.command) - @nomination_group.command(name='watched', aliases=('all', 'list')) + @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",)) @with_role(*MODERATION_ROLES) async def watched_command( self, ctx: Context, oldest_first: bool = False, update_cache: bool = True @@ -63,7 +63,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): """ await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) - @nomination_group.command(name='watch', aliases=('w', 'add', 'a')) + @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",)) @with_role(*STAFF_ROLES) async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """ @@ -157,7 +157,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): max_size=1000 ) - @nomination_group.command(name='unwatch', aliases=('end', )) + @nomination_group.command(name='unwatch', aliases=('end', ), root_aliases=("unnominate",)) @with_role(*MODERATION_ROLES) async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """ -- cgit v1.2.3 From 520ac0f9871bf6775d76eea753ed2a940704e92d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 20:44:48 -0700 Subject: Include root aliases in the command name conflict test --- tests/bot/cogs/test_cogs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index fdda59a8f..30a04422a 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -53,6 +53,7 @@ class CommandNameTests(unittest.TestCase): """Return a list of all qualified names, including aliases, for the `command`.""" names = [f"{command.full_parent_name} {alias}".strip() for alias in command.aliases] names.append(command.qualified_name) + names += getattr(command, "root_aliases", []) return names -- cgit v1.2.3 From fa92df15a4644d01256edeb440242ae92dc8adf0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 20:52:21 -0700 Subject: Help: include root aliases in output --- bot/cogs/help.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 3d1d6fd10..25ce4ae0f 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -189,7 +189,9 @@ class CustomHelpCommand(HelpCommand): command_details = f"**```{PREFIX}{name} {command.signature}```**\n" # show command aliases - aliases = ", ".join(f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases) + aliases = [f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases] + aliases += [f"`{alias}`" for alias in getattr(command, "root_aliases", ())] + aliases = ", ".join(sorted(aliases)) if aliases: command_details += f"**Can also use:** {aliases}\n\n" -- cgit v1.2.3 From 075110f6300da0525dec0aadb6530409549a02f5 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 23 Aug 2020 14:36:06 +0100 Subject: Address review comments from @kwzrd --- bot/cogs/information.py | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 776a0d474..c9412948a 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -20,6 +20,12 @@ from bot.utils.time import time_since log = logging.getLogger(__name__) +STATUS_EMOTES = { + Status.offline: constants.Emojis.status_offline, + Status.dnd: constants.Emojis.status_dnd, + Status.idle: constants.Emojis.status_idle +} + class Information(Cog): """A cog with commands for generating embeds with server info, such as server stats and user info.""" @@ -184,18 +190,6 @@ class Information(Cog): await ctx.send(embed=embed) - @staticmethod - def status_to_emoji(status: Status) -> str: - """Convert a Discord status into the relevant emoji.""" - if status is Status.offline: - return constants.Emojis.status_offline - elif status is Status.dnd: - return constants.Emojis.status_dnd - elif status is Status.idle: - return constants.Emojis.status_idle - else: - return constants.Emojis.status_online - @command(name="user", aliases=["user_info", "member", "member_info"]) async def user_info(self, ctx: Context, user: Member = None) -> None: """Returns info about a user.""" @@ -231,6 +225,7 @@ class Information(Cog): emoji = "" if activity.emoji: + # Confirm that the emoji is not a custom emoji since we cannot use them. if not activity.emoji.id: emoji += activity.emoji.name + " " @@ -240,18 +235,18 @@ class Information(Cog): if user.nick: name = f"{user.nick} ({name})" - badges = "" + badges = [] for badge, is_set in user.public_flags: - if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}")): - badges += emoji + " " + if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}", None)): + badges.append(emoji) joined = time_since(user.joined_at, max_units=3) roles = ", ".join(role.mention for role in user.roles[1:]) - desktop_status = self.status_to_emoji(user.desktop_status) - web_status = self.status_to_emoji(user.web_status) - mobile_status = self.status_to_emoji(user.mobile_status) + desktop_status = STATUS_EMOTES.get(user.desktop_status, constants.Emojis.status_online) + web_status = STATUS_EMOTES.get(user.web_status, constants.Emojis.status_online) + mobile_status = STATUS_EMOTES.get(user.mobile_status, constants.Emojis.status_online) fields = [ ( @@ -290,7 +285,7 @@ class Information(Cog): # Let's build the embed now embed = Embed( title=name, - description=badges + description=" ".join(badges) ) for field_name, field_content in fields: -- cgit v1.2.3 From 2448c0530e24cb0aacb733f17dea7a6830fdd98b Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 23 Aug 2020 15:11:41 +0100 Subject: Don't just exclude custom emoji, include the name of the emote --- bot/cogs/information.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index c9412948a..3ec6c33af 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -225,9 +225,11 @@ class Information(Cog): emoji = "" if activity.emoji: - # Confirm that the emoji is not a custom emoji since we cannot use them. + # If an emoji is unicode use the emoji, else write the emote like :abc: if not activity.emoji.id: emoji += activity.emoji.name + " " + else: + emoji += f"`:{activity.emoji.name}:` " custom_status = f'Status: {emoji}{state}\n' -- cgit v1.2.3 From fa28bf6de4dd5c5412b68d4ad448e0b4cb15cfac Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Aug 2020 10:10:38 -0700 Subject: Type check root aliases Just like normal aliases, they should only be tuples or lists. This is likely done by discord.py to prevent accidentally passing a string when only a single alias is desired. --- bot/command.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/command.py b/bot/command.py index 92e61d97e..0fb900f7b 100644 --- a/bot/command.py +++ b/bot/command.py @@ -13,3 +13,6 @@ class Command(commands.Command): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.root_aliases = kwargs.get("root_aliases", []) + + if not isinstance(self.root_aliases, (list, tuple)): + raise TypeError("Root aliases of a command must be a list or a tuple of strings.") -- cgit v1.2.3 From 474d78704d852eec106df8d6f64783d0216f4b7f Mon Sep 17 00:00:00 2001 From: Boris Muratov Date: Wed, 26 Aug 2020 02:42:20 +0300 Subject: Bold link to asking guide in embeds --- 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 57094751e..541c6f336 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -36,7 +36,7 @@ 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}). +check out our guide on [**asking good questions**]({ASKING_GUIDE_URL}). """ DORMANT_MSG = f""" @@ -47,7 +47,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}). """ CoroutineFunc = t.Callable[..., t.Coroutine] -- cgit v1.2.3 From a565bb529cbfcb241a7cf63c114a17f695451721 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 26 Aug 2020 18:31:42 +0200 Subject: Verification: add guild invite to config --- bot/constants.py | 1 + config-default.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 0902858ac..daef6c095 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -458,6 +458,7 @@ class Guild(metaclass=YAMLGetter): section = "guild" id: int + invite: str # Discord invite, gets embedded in chat moderation_channels: List[int] moderation_roles: List[int] modlog_blacklist: List[int] diff --git a/config-default.yml b/config-default.yml index 58bdbe20f..a98fd14ef 100644 --- a/config-default.yml +++ b/config-default.yml @@ -123,6 +123,7 @@ style: guild: id: 267624335836053506 + invite: "https://discord.gg/python" categories: help_available: 691405807388196926 -- cgit v1.2.3 From 53189e815a5c260bb2636914ecd79f4f4c1182a0 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 26 Aug 2020 18:33:16 +0200 Subject: Verification: send guild invite with kick message Makes it easy for users to re-join. Co-authored-by: Joe Banks --- bot/cogs/verification.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 08d54d575..56b469a3e 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -62,7 +62,8 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! # Sent via DMs to users kicked for failing to verify KICKED_MESSAGE = f""" Hi! You have been automatically kicked from Python Discord as you have failed to accept our rules \ -within `{KICKED_AFTER}` days. If this was an accident, please feel free to join again. +within `{KICKED_AFTER}` days. If this was an accident, please feel free to join us again! +{constants.Guild.invite} """ # Sent periodically in the verification channel -- cgit v1.2.3 From 3ea04e3ddeaeef1d7931df8f5b84293d5eac6a04 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 26 Aug 2020 18:50:57 +0200 Subject: Verification: separate guild invite by empty line Co-authored-by: Joe Banks --- bot/cogs/verification.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 56b469a3e..a35681988 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -63,6 +63,7 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! KICKED_MESSAGE = f""" Hi! You have been automatically kicked from Python Discord as you have failed to accept our rules \ within `{KICKED_AFTER}` days. If this was an accident, please feel free to join us again! + {constants.Guild.invite} """ -- cgit v1.2.3 From eff2f75321fce0e8a8d11a1c85c2dad48552ded8 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 26 Aug 2020 19:34:14 +0200 Subject: Verification: remove explicit everyones from allowed mentions If the kwarg isn't passed, it uses the value that was given to the bot on init (False), despite the kwarg defaulting to True. Thanks to Mark and Senjan for helping me understand this. Co-authored-by: MarkKoz Co-authored-by: Senjan21 <53477086+senjan21@users.noreply.github.com> --- bot/cogs/verification.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index a35681988..a0a82be0c 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -79,12 +79,8 @@ You will be kicked if you don't verify within `{KICKED_AFTER}` days. REMINDER_FREQUENCY = 28 # Hours to wait between sending `REMINDER_MESSAGE` -MENTION_CORE_DEVS = discord.AllowedMentions( - everyone=False, roles=[discord.Object(constants.Roles.core_developers)] -) -MENTION_UNVERIFIED = discord.AllowedMentions( - everyone=False, roles=[discord.Object(constants.Roles.unverified)] -) +MENTION_CORE_DEVS = discord.AllowedMentions(roles=[discord.Object(constants.Roles.core_developers)]) +MENTION_UNVERIFIED = discord.AllowedMentions(roles=[discord.Object(constants.Roles.unverified)]) # An async function taking a Member param Request = t.Callable[[discord.Member], t.Awaitable] -- cgit v1.2.3 From 44aae4528ecec5eef8e2b56f7ac851219b35f080 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 26 Aug 2020 19:42:31 +0200 Subject: Verification: retain ping in edited confirmation msg Prevent a ghost ping from occurring upon reaction. Co-authored-by: Senjan21 <53477086+senjan21@users.noreply.github.com> --- bot/cogs/verification.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index a0a82be0c..d21395a1c 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -192,11 +192,12 @@ class Verification(Cog): # Since `n_members` is a suspiciously large number, we will ask for confirmation log.debug("Amount of users is too large, requesting staff confirmation") - core_devs = pydis.get_channel(constants.Channels.dev_core) - confirmation_msg = await core_devs.send( - f"<@&{constants.Roles.core_developers}> Verification determined that `{n_members}` members should " - f"be kicked as they haven't verified in `{KICKED_AFTER}` days. This is `{percentage:.2%}` of the " - f"guild's population. Proceed?", + core_dev_channel = pydis.get_channel(constants.Channels.dev_core) + core_dev_ping = f"<@&{constants.Roles.core_developers}>" + + confirmation_msg = await core_dev_channel.send( + f"{core_dev_ping} Verification determined that `{n_members}` members should be kicked as they haven't " + f"verified in `{KICKED_AFTER}` days. This is `{percentage:.2%}` of the guild's population. Proceed?", allowed_mentions=MENTION_CORE_DEVS, ) @@ -229,9 +230,9 @@ class Verification(Cog): # Edit the prompt message to reflect the final choice if result is True: - result_msg = f":ok_hand: Request to kick `{n_members}` members was authorized!" + result_msg = f":ok_hand: {core_dev_ping} Request to kick `{n_members}` members was authorized!" else: - result_msg = f":warning: Request to kick `{n_members}` members was denied!" + result_msg = f":warning: {core_dev_ping} Request to kick `{n_members}` members was denied!" with suppress(discord.HTTPException): await confirmation_msg.edit(content=result_msg) -- cgit v1.2.3 From 3672da9d6ad16452205e00a86162314f457fbbd0 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 26 Aug 2020 22:26:15 +0200 Subject: Verification: add helper for alerting admins --- bot/cogs/verification.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index d21395a1c..d5216c7c0 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -79,6 +79,7 @@ You will be kicked if you don't verify within `{KICKED_AFTER}` days. REMINDER_FREQUENCY = 28 # Hours to wait between sending `REMINDER_MESSAGE` +MENTION_ADMINS = discord.AllowedMentions(roles=[discord.Object(constants.Roles.admins)]) MENTION_CORE_DEVS = discord.AllowedMentions(roles=[discord.Object(constants.Roles.core_developers)]) MENTION_UNVERIFIED = discord.AllowedMentions(roles=[discord.Object(constants.Roles.unverified)]) @@ -239,6 +240,23 @@ class Verification(Cog): return result + async def _alert_admins(self, exception: discord.HTTPException) -> None: + """ + Ping @Admins with information about `exception`. + + This is used when a critical `exception` caused a verification task to abort. + """ + await self.bot.wait_until_guild_available() + log.info(f"Sending admin alert regarding exception: {exception}") + + admins_channel = self.bot.get_guild(constants.Guild.id).get_channel(constants.Channels.admins) + ping = f"<@&{constants.Roles.admins}>" + + await admins_channel.send( + f"{ping} Aborted updating unverified users due to the following exception:\n```{exception}```", + allowed_mentions=MENTION_ADMINS, + ) + async def _send_requests(self, members: t.Collection[discord.Member], request: Request, limit: Limit) -> int: """ Pass `members` one by one to `request` handling Discord exceptions. -- cgit v1.2.3 From 1d6d845d2f052c74d6d92a1a98b537430296cc85 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 26 Aug 2020 22:27:47 +0200 Subject: Verification: stop kicking members on suspicious 403 A Discord error code 50_0007 signifies that the DM dispatch failed because the target user does not accept DMs from the bot. Such errors are ignored as before. Any other 403s will however cause the bot to stop making requests. This is in case the bot gets caught by an anti-spam filter and should immediately stop. --- bot/cogs/verification.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index d5216c7c0..196808b0d 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -87,6 +87,14 @@ MENTION_UNVERIFIED = discord.AllowedMentions(roles=[discord.Object(constants.Rol Request = t.Callable[[discord.Member], t.Awaitable] +class StopExecution(Exception): + """Signals that a task should halt immediately & alert admins.""" + + def __init__(self, reason: discord.HTTPException) -> None: + super().__init__() + self.reason = reason + + class Limit(t.NamedTuple): """Composition over config for throttling requests.""" @@ -277,6 +285,9 @@ class Verification(Cog): continue try: await request(member) + except StopExecution as stop_execution: + await self._alert_admins(stop_execution.reason) + break except discord.HTTPException as http_exc: bad_statuses.add(http_exc.status) else: @@ -304,8 +315,12 @@ class Verification(Cog): async def kick_request(member: discord.Member) -> None: """Send `KICKED_MESSAGE` to `member` and kick them from the guild.""" - with suppress(discord.Forbidden): + try: await member.send(KICKED_MESSAGE) + except discord.Forbidden as exc_403: + log.trace(f"DM dispatch failed on 403 error with code: {exc_403.code}") + if exc_403.code != 50_007: # 403 raised for any other reason than disabled DMs + raise StopExecution(reason=exc_403) await member.kick(reason=f"User has not verified in {KICKED_AFTER} days") n_kicked = await self._send_requests(members, kick_request, Limit(batch_size=2, sleep_secs=1)) -- cgit v1.2.3 From 0ba23a7c47813280ab6157396e43d61e8fc7b4d2 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 27 Aug 2020 10:07:23 +0200 Subject: Verification: document StopExecution handling --- bot/cogs/verification.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 196808b0d..984f3cc95 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -272,6 +272,9 @@ class Verification(Cog): This coroutine serves as a generic `request` executor for kicking members and adding roles, as it allows us to define the error handling logic in one place only. + Any `request` has the ability to completely abort the execution by raising `StopExecution`. + In such a case, the @Admins will be alerted of the reason attribute. + To avoid rate-limits, pass a `limit` configuring the batch size and the amount of seconds to sleep between batches. -- cgit v1.2.3 From 69314d4b812361f2b2a02018093f9a504ac4674f Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 27 Aug 2020 14:29:21 +0200 Subject: Verification: improve allowed mentions handling I really didn't like the constants, but the construction of allowed mentions instances is syntactically noisy, so I prefer to keep it out of the important logic. Abstracting it behind a function seems to be the best approach yet. --- bot/cogs/verification.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 984f3cc95..0ae3c5b4c 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -79,10 +79,6 @@ You will be kicked if you don't verify within `{KICKED_AFTER}` days. REMINDER_FREQUENCY = 28 # Hours to wait between sending `REMINDER_MESSAGE` -MENTION_ADMINS = discord.AllowedMentions(roles=[discord.Object(constants.Roles.admins)]) -MENTION_CORE_DEVS = discord.AllowedMentions(roles=[discord.Object(constants.Roles.core_developers)]) -MENTION_UNVERIFIED = discord.AllowedMentions(roles=[discord.Object(constants.Roles.unverified)]) - # An async function taking a Member param Request = t.Callable[[discord.Member], t.Awaitable] @@ -102,6 +98,11 @@ class Limit(t.NamedTuple): sleep_secs: int # Sleep this many seconds after each batch +def mention_role(role_id: int) -> discord.AllowedMentions: + """Construct an allowed mentions instance that allows pinging `role_id`.""" + return discord.AllowedMentions(roles=[discord.Object(role_id)]) + + def is_verified(member: discord.Member) -> bool: """ Check whether `member` is considered verified. @@ -207,7 +208,7 @@ class Verification(Cog): confirmation_msg = await core_dev_channel.send( f"{core_dev_ping} Verification determined that `{n_members}` members should be kicked as they haven't " f"verified in `{KICKED_AFTER}` days. This is `{percentage:.2%}` of the guild's population. Proceed?", - allowed_mentions=MENTION_CORE_DEVS, + allowed_mentions=mention_role(constants.Roles.core_developers), ) options = (constants.Emojis.incident_actioned, constants.Emojis.incident_unactioned) @@ -262,7 +263,7 @@ class Verification(Cog): await admins_channel.send( f"{ping} Aborted updating unverified users due to the following exception:\n```{exception}```", - allowed_mentions=MENTION_ADMINS, + allowed_mentions=mention_role(constants.Roles.admins), ) async def _send_requests(self, members: t.Collection[discord.Member], request: Request, limit: Limit) -> int: @@ -445,7 +446,9 @@ class Verification(Cog): await self.bot.http.delete_message(verification.id, last_reminder) log.trace("Sending verification reminder") - new_reminder = await verification.send(REMINDER_MESSAGE, allowed_mentions=MENTION_UNVERIFIED) + new_reminder = await verification.send( + REMINDER_MESSAGE, allowed_mentions=mention_role(constants.Roles.unverified), + ) await self.task_cache.set("last_reminder", new_reminder.id) -- cgit v1.2.3 From 30cbde7a7c48e59a19b5a7f1934d0e7674473d62 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 27 Aug 2020 12:26:27 -0700 Subject: AntiSpam: ignore custom emojis in code blocks In code blocks, custom emojis render as text rather than as images. Therefore, they probably aren't being spammed and should be ignored. Fix #1130 --- bot/rules/discord_emojis.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/rules/discord_emojis.py b/bot/rules/discord_emojis.py index 5bab514f2..6e47f0197 100644 --- a/bot/rules/discord_emojis.py +++ b/bot/rules/discord_emojis.py @@ -5,6 +5,7 @@ from discord import Member, Message DISCORD_EMOJI_RE = re.compile(r"<:\w+:\d+>") +CODE_BLOCK_RE = re.compile(r"```.*?```", flags=re.DOTALL) async def apply( @@ -17,8 +18,9 @@ async def apply( if msg.author == last_message.author ) + # Get rid of code blocks in the message before searching for emojis. total_emojis = sum( - len(DISCORD_EMOJI_RE.findall(msg.content)) + len(DISCORD_EMOJI_RE.findall(CODE_BLOCK_RE.sub("", msg.content))) for msg in relevant_messages ) -- cgit v1.2.3 From 7016124192f3228145195765b1c94535700e54aa Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Thu, 27 Aug 2020 23:14:38 +0100 Subject: Update Discord Partner badge --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 8c0092e76..b4dc34e85 100644 --- a/config-default.yml +++ b/config-default.yml @@ -39,7 +39,7 @@ style: status_offline: "<:status_offline:470326266537705472>" badge_staff: "<:discord_staff:743882896498098226>" - badge_partner: "<:partner:743882897131569323>" + badge_partner: "<:partner:748666453242413136>" badge_hypesquad: "<:hypesquad_events:743882896892362873>" badge_bug_hunter: "<:bug_hunter_lvl1:743882896372269137>" badge_hypesquad_bravery: "<:hypesquad_bravery:743882896745693335>" -- cgit v1.2.3 From 2fcf07fd041fa58beca52cfa33540343b54e85fd Mon Sep 17 00:00:00 2001 From: AtieP Date: Sat, 29 Aug 2020 09:10:45 +0200 Subject: Remove unused variables and imports --- bot/cogs/help.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 76aaf655c..6caa211a6 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -10,7 +10,7 @@ from fuzzywuzzy import fuzz, process from fuzzywuzzy.utils import full_process from bot import constants -from bot.constants import Channels, Emojis, STAFF_ROLES +from bot.constants import Channels, STAFF_ROLES from bot.decorators import redirect_output from bot.pagination import LinePaginator from bot.utils.messages import wait_for_deletion @@ -18,7 +18,6 @@ from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) COMMANDS_PER_PAGE = 8 -DELETE_EMOJI = Emojis.trashcan PREFIX = constants.Bot.prefix Category = namedtuple("Category", ["name", "description", "cogs"]) -- cgit v1.2.3 From 8b10533851ca3fe3b44dd6662f634ae89550ad16 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Sat, 29 Aug 2020 01:38:04 -0700 Subject: Completely gutted the wolfram command. Moved to seasonalbot/bot/exts/evergreen/wolfram.py --- bot/cogs/wolfram.py | 280 ---------------------------------------------------- bot/constants.py | 8 -- config-default.yml | 7 -- 3 files changed, 295 deletions(-) delete mode 100644 bot/cogs/wolfram.py diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py deleted file mode 100644 index e6cae3bb8..000000000 --- a/bot/cogs/wolfram.py +++ /dev/null @@ -1,280 +0,0 @@ -import logging -from io import BytesIO -from typing import Callable, List, Optional, Tuple -from urllib import parse - -import discord -from dateutil.relativedelta import relativedelta -from discord import Embed -from discord.ext import commands -from discord.ext.commands import BucketType, Cog, Context, check, group - -from bot.bot import Bot -from bot.constants import Colours, STAFF_ROLES, Wolfram -from bot.pagination import ImagePaginator -from bot.utils.time import humanize_delta - -log = logging.getLogger(__name__) - -APPID = Wolfram.key -DEFAULT_OUTPUT_FORMAT = "JSON" -QUERY = "http://api.wolframalpha.com/v2/{request}?{data}" -WOLF_IMAGE = "https://www.symbols.com/gi.php?type=1&id=2886&i=1" - -MAX_PODS = 20 - -# Allows for 10 wolfram calls pr user pr day -usercd = commands.CooldownMapping.from_cooldown(Wolfram.user_limit_day, 60*60*24, BucketType.user) - -# Allows for max api requests / days in month per day for the entire guild (Temporary) -guildcd = commands.CooldownMapping.from_cooldown(Wolfram.guild_limit_day, 60*60*24, BucketType.guild) - - -async def send_embed( - ctx: Context, - message_txt: str, - colour: int = Colours.soft_red, - footer: str = None, - img_url: str = None, - f: discord.File = None -) -> None: - """Generate & send a response embed with Wolfram as the author.""" - embed = Embed(colour=colour) - embed.description = message_txt - embed.set_author(name="Wolfram Alpha", - icon_url=WOLF_IMAGE, - url="https://www.wolframalpha.com/") - if footer: - embed.set_footer(text=footer) - - if img_url: - embed.set_image(url=img_url) - - await ctx.send(embed=embed, file=f) - - -def custom_cooldown(*ignore: List[int]) -> Callable: - """ - Implement per-user and per-guild cooldowns for requests to the Wolfram API. - - A list of roles may be provided to ignore the per-user cooldown - """ - async def predicate(ctx: Context) -> bool: - if ctx.invoked_with == 'help': - # if the invoked command is help we don't want to increase the ratelimits since it's not actually - # invoking the command/making a request, so instead just check if the user/guild are on cooldown. - guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0 # if guild is on cooldown - if not any(r.id in ignore for r in ctx.author.roles): # check user bucket if user is not ignored - return guild_cooldown and not usercd.get_bucket(ctx.message).get_tokens() == 0 - return guild_cooldown - - user_bucket = usercd.get_bucket(ctx.message) - - if all(role.id not in ignore for role in ctx.author.roles): - user_rate = user_bucket.update_rate_limit() - - if user_rate: - # Can't use api; cause: member limit - delta = relativedelta(seconds=int(user_rate)) - cooldown = humanize_delta(delta) - message = ( - "You've used up your limit for Wolfram|Alpha requests.\n" - f"Cooldown: {cooldown}" - ) - await send_embed(ctx, message) - return False - - guild_bucket = guildcd.get_bucket(ctx.message) - guild_rate = guild_bucket.update_rate_limit() - - # Repr has a token attribute to read requests left - log.debug(guild_bucket) - - if guild_rate: - # Can't use api; cause: guild limit - message = ( - "The max limit of requests for the server has been reached for today.\n" - f"Cooldown: {int(guild_rate)}" - ) - await send_embed(ctx, message) - return False - - return True - return check(predicate) - - -async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tuple]]: - """Get the Wolfram API pod pages for the provided query.""" - async with ctx.channel.typing(): - url_str = parse.urlencode({ - "input": query, - "appid": APPID, - "output": DEFAULT_OUTPUT_FORMAT, - "format": "image,plaintext" - }) - request_url = QUERY.format(request="query", data=url_str) - - async with bot.http_session.get(request_url) as response: - json = await response.json(content_type='text/plain') - - result = json["queryresult"] - - if result["error"]: - # API key not set up correctly - if result["error"]["msg"] == "Invalid appid": - message = "Wolfram API key is invalid or missing." - log.warning( - "API key seems to be missing, or invalid when " - f"processing a wolfram request: {url_str}, Response: {json}" - ) - await send_embed(ctx, message) - return - - message = "Something went wrong internally with your request, please notify staff!" - log.warning(f"Something went wrong getting a response from wolfram: {url_str}, Response: {json}") - await send_embed(ctx, message) - return - - if not result["success"]: - message = f"I couldn't find anything for {query}." - await send_embed(ctx, message) - return - - if not result["numpods"]: - message = "Could not find any results." - await send_embed(ctx, message) - return - - pods = result["pods"] - pages = [] - for pod in pods[:MAX_PODS]: - subs = pod.get("subpods") - - for sub in subs: - title = sub.get("title") or sub.get("plaintext") or sub.get("id", "") - img = sub["img"]["src"] - pages.append((title, img)) - return pages - - -class Wolfram(Cog): - """Commands for interacting with the Wolfram|Alpha API.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_command(self, ctx: Context, *, query: str) -> None: - """Requests all answers on a single image, sends an image of all related pods.""" - url_str = parse.urlencode({ - "i": query, - "appid": APPID, - }) - query = QUERY.format(request="simple", data=url_str) - - # Give feedback that the bot is working. - async with ctx.channel.typing(): - async with self.bot.http_session.get(query) as response: - status = response.status - image_bytes = await response.read() - - f = discord.File(BytesIO(image_bytes), filename="image.png") - image_url = "attachment://image.png" - - if status == 501: - message = "Failed to get response" - footer = "" - color = Colours.soft_red - elif status == 400: - message = "No input found" - footer = "" - color = Colours.soft_red - elif status == 403: - message = "Wolfram API key is invalid or missing." - footer = "" - color = Colours.soft_red - else: - message = "" - footer = "View original for a bigger picture." - color = Colours.soft_orange - - # Sends a "blank" embed if no request is received, unsure how to fix - await send_embed(ctx, message, color, footer=footer, img_url=image_url, f=f) - - @wolfram_command.command(name="page", aliases=("pa", "p")) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_page_command(self, ctx: Context, *, query: str) -> None: - """ - Requests a drawn image of given query. - - Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. - """ - pages = await get_pod_pages(ctx, self.bot, query) - - if not pages: - return - - embed = Embed() - embed.set_author(name="Wolfram Alpha", - icon_url=WOLF_IMAGE, - url="https://www.wolframalpha.com/") - embed.colour = Colours.soft_orange - - await ImagePaginator.paginate(pages, ctx, embed) - - @wolfram_command.command(name="cut", aliases=("c",)) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_cut_command(self, ctx: Context, *, query: str) -> None: - """ - Requests a drawn image of given query. - - Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. - """ - pages = await get_pod_pages(ctx, self.bot, query) - - if not pages: - return - - if len(pages) >= 2: - page = pages[1] - else: - page = pages[0] - - await send_embed(ctx, page[0], colour=Colours.soft_orange, img_url=page[1]) - - @wolfram_command.command(name="short", aliases=("sh", "s")) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_short_command(self, ctx: Context, *, query: str) -> None: - """Requests an answer to a simple question.""" - url_str = parse.urlencode({ - "i": query, - "appid": APPID, - }) - query = QUERY.format(request="result", data=url_str) - - # Give feedback that the bot is working. - async with ctx.channel.typing(): - async with self.bot.http_session.get(query) as response: - status = response.status - response_text = await response.text() - - if status == 501: - message = "Failed to get response" - color = Colours.soft_red - elif status == 400: - message = "No input found" - color = Colours.soft_red - elif response_text == "Error 1: Invalid appid": - message = "Wolfram API key is invalid or missing." - color = Colours.soft_red - else: - message = response_text - color = Colours.soft_orange - - await send_embed(ctx, message, color) - - -def setup(bot: Bot) -> None: - """Load the Wolfram cog.""" - bot.add_cog(Wolfram(bot)) diff --git a/bot/constants.py b/bot/constants.py index f3db80279..17fe34e95 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -514,14 +514,6 @@ class Reddit(metaclass=YAMLGetter): secret: Optional[str] -class Wolfram(metaclass=YAMLGetter): - section = "wolfram" - - user_limit_day: int - guild_limit_day: int - key: Optional[str] - - class AntiSpam(metaclass=YAMLGetter): section = 'anti_spam' diff --git a/config-default.yml b/config-default.yml index b4dc34e85..a0f601728 100644 --- a/config-default.yml +++ b/config-default.yml @@ -393,13 +393,6 @@ reddit: secret: !ENV "REDDIT_SECRET" -wolfram: - # Max requests per day. - user_limit_day: 10 - guild_limit_day: 67 - key: !ENV "WOLFRAM_API_KEY" - - big_brother: log_delay: 15 header_message_limit: 15 -- cgit v1.2.3 From c58ae662069d098dae45a36a5203b6d5f0048924 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 29 Aug 2020 18:29:24 +0200 Subject: Verification: add helper for stopping tasks --- bot/cogs/verification.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 0ae3c5b4c..a013a1b12 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -157,8 +157,7 @@ class Verification(Cog): This is necessary, as tasks are not automatically cancelled on cog unload. """ - self.update_unverified_members.cancel() - self.ping_unverified.cancel() + self._stop_tasks(gracefully=False) @property def mod_log(self) -> ModLog: @@ -179,6 +178,21 @@ class Verification(Cog): self.update_unverified_members.start() self.ping_unverified.start() + def _stop_tasks(self, *, gracefully: bool) -> None: + """ + Stop the update users & ping @Unverified tasks. + + If `gracefully` is True, the tasks will be able to finish their current iteration. + Otherwise, they are cancelled immediately. + """ + log.info(f"Stopping internal tasks ({gracefully=})") + if gracefully: + self.update_unverified_members.stop() + self.ping_unverified.stop() + else: + self.update_unverified_members.cancel() + self.ping_unverified.cancel() + # region: automatically update unverified users async def _verify_kick(self, n_members: int) -> bool: @@ -607,9 +621,7 @@ class Verification(Cog): """Stop verification tasks.""" log.info("Stopping verification tasks") - self.update_unverified_members.cancel() - self.ping_unverified.cancel() - + self._stop_tasks(gracefully=False) await self.task_cache.set("tasks_running", 0) colour = discord.Colour.blurple() -- cgit v1.2.3 From cf7388ff8490be95f1d677f424e8bec86de0e46a Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 29 Aug 2020 18:32:12 +0200 Subject: Verification: stop tasks on suspicious 403 --- bot/cogs/verification.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index a013a1b12..107ae1178 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -276,7 +276,9 @@ class Verification(Cog): ping = f"<@&{constants.Roles.admins}>" await admins_channel.send( - f"{ping} Aborted updating unverified users due to the following exception:\n```{exception}```", + f"{ping} Aborted updating unverified users due to the following exception:\n" + f"```{exception}```\n" + f"Internal tasks will be stopped.", allowed_mentions=mention_role(constants.Roles.admins), ) @@ -305,6 +307,7 @@ class Verification(Cog): await request(member) except StopExecution as stop_execution: await self._alert_admins(stop_execution.reason) + self._stop_tasks(gracefully=True) # Gracefully finish current iteration, then stop break except discord.HTTPException as http_exc: bad_statuses.add(http_exc.status) -- cgit v1.2.3 From ad305f2eb3bb33f5ce7bb3abd7def9f436928c8e Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 29 Aug 2020 18:37:32 +0200 Subject: Verification: denote `_maybe_start_tasks` as private Consistency with the new `_stop_tasks` method. --- bot/cogs/verification.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 107ae1178..5c8962577 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -149,7 +149,7 @@ class Verification(Cog): def __init__(self, bot: Bot) -> None: """Start internal tasks.""" self.bot = bot - self.bot.loop.create_task(self.maybe_start_tasks()) + self.bot.loop.create_task(self._maybe_start_tasks()) def cog_unload(self) -> None: """ @@ -164,7 +164,7 @@ class Verification(Cog): """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") - async def maybe_start_tasks(self) -> None: + async def _maybe_start_tasks(self) -> None: """ Poll Redis to check whether internal tasks should start. -- cgit v1.2.3 From 6c98df046c535282c6d4b194b0766afcddbdf669 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 29 Aug 2020 19:54:27 +0200 Subject: Verification: set 'tasks_running' to 0 on suspicious 403s Prevent the tasks from starting again if the bot restarts. --- bot/cogs/verification.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 5c8962577..08f7c282e 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -307,6 +307,7 @@ class Verification(Cog): await request(member) except StopExecution as stop_execution: await self._alert_admins(stop_execution.reason) + await self.task_cache.set("tasks_running", 0) self._stop_tasks(gracefully=True) # Gracefully finish current iteration, then stop break except discord.HTTPException as http_exc: -- cgit v1.2.3 From 40ad0def564109884c607c78f95c67518d7a70a5 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Fri, 28 Aug 2020 11:53:08 -0500 Subject: Everyone Ping: Add rules to default config file --- config-default.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config-default.yml b/config-default.yml index 8c0092e76..3a5918983 100644 --- a/config-default.yml +++ b/config-default.yml @@ -385,6 +385,9 @@ anti_spam: interval: 10 max: 3 + everyone_ping: + enabled: true + reddit: subreddits: -- cgit v1.2.3 From df4ef2e520cd672f0bb46b9d5d09a04647ca2ccf Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Fri, 28 Aug 2020 19:06:19 -0500 Subject: Everyone Ping: Added rule Added the filter rule to the bot/rules folder. --- bot/rules/everyone_ping.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 bot/rules/everyone_ping.py diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py new file mode 100644 index 000000000..29a734478 --- /dev/null +++ b/bot/rules/everyone_ping.py @@ -0,0 +1,31 @@ +from typing import Dict, Iterable, List, Optional, Tuple + +from discord import Member, Message + + +async def apply( + last_message: Message, + recent_messages: List[Message], + config: Dict[str, int], +) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: + """Detects if a user has sent an '@everyone' ping.""" + relevant_messages = tuple( + msg for msg in recent_messages if msg.author == last_message.author + ) + + ev_msgs_ct = 0 + if config["enabled"]: + for msg in relevant_messages: + ev_role = msg.guild.default_role + msg_roles = msg.role_mentions + + if ev_role in msg_roles: + ev_msgs_ct += 1 + + if ev_msgs_ct > 0: + return ( + f"pinged the everyone role {ev_msgs_ct} times", + (last_message.author), + relevant_messages, + ) + return None -- cgit v1.2.3 From 99aa7d55a72fdbf4265820e9d6f70d95132faa8f Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Fri, 28 Aug 2020 19:14:29 -0500 Subject: Everyone Ping: Added rule to recognized rules Added mapping to anti-spam cog, then also edited __init__ in the rules folder to expose the apply function. --- bot/cogs/antispam.py | 3 ++- bot/rules/__init__.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index bc31cbd95..d003f962b 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -34,7 +34,8 @@ RULE_FUNCTION_MAPPING = { 'links': rules.apply_links, 'mentions': rules.apply_mentions, 'newlines': rules.apply_newlines, - 'role_mentions': rules.apply_role_mentions + 'role_mentions': rules.apply_role_mentions, + 'everyone_ping': rules.apply_everyone_ping, } diff --git a/bot/rules/__init__.py b/bot/rules/__init__.py index a01ceae73..8a69cadee 100644 --- a/bot/rules/__init__.py +++ b/bot/rules/__init__.py @@ -10,3 +10,4 @@ from .links import apply as apply_links from .mentions import apply as apply_mentions from .newlines import apply as apply_newlines from .role_mentions import apply as apply_role_mentions +from .everyone_ping import apply as apply_everyone_ping -- cgit v1.2.3 From f873e685e34f1af62f2bc49bc3e37265c327b3ea Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Fri, 28 Aug 2020 19:30:34 -0500 Subject: Everyone Ping: Added required values to config The `max` and `interval` values were required, so they were added to the config file and the rule was modified to accept these new values. --- bot/rules/everyone_ping.py | 2 +- config-default.yml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 29a734478..342727093 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -14,7 +14,7 @@ async def apply( ) ev_msgs_ct = 0 - if config["enabled"]: + if config["max"]: for msg in relevant_messages: ev_role = msg.guild.default_role msg_roles = msg.role_mentions diff --git a/config-default.yml b/config-default.yml index 3a5918983..18d7f4b0e 100644 --- a/config-default.yml +++ b/config-default.yml @@ -386,7 +386,8 @@ anti_spam: max: 3 everyone_ping: - enabled: true + interval: 1 + max: 1 reddit: -- cgit v1.2.3 From c55b7e3749166d06f66193692a7ded5d1317a154 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Fri, 28 Aug 2020 20:21:01 -0500 Subject: Everyone Ping: Fixed rule, edited config Changed the method of checking for an everyone ping. Also changed the config to act as `min pings` instead of `ping enabled/disabled`. --- bot/rules/everyone_ping.py | 16 ++++++---------- config-default.yml | 4 ++-- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 342727093..bfc400831 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -14,18 +14,14 @@ async def apply( ) ev_msgs_ct = 0 - if config["max"]: - for msg in relevant_messages: - ev_role = msg.guild.default_role - msg_roles = msg.role_mentions + for msg in relevant_messages: + if '@everyone' in msg.content: + ev_msgs_ct += 1 - if ev_role in msg_roles: - ev_msgs_ct += 1 - - if ev_msgs_ct > 0: + if ev_msgs_ct >= config['max']: return ( - f"pinged the everyone role {ev_msgs_ct} times", - (last_message.author), + f"pinged the everyone role {ev_msgs_ct} times in {config['interval']}s", + (last_message.author,), relevant_messages, ) return None diff --git a/config-default.yml b/config-default.yml index 18d7f4b0e..8546b5310 100644 --- a/config-default.yml +++ b/config-default.yml @@ -386,8 +386,8 @@ anti_spam: max: 3 everyone_ping: - interval: 1 - max: 1 + interval: 10 + max: 0 reddit: -- cgit v1.2.3 From 24002b6b585962bf9218ad643727b30d4ed018dd Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sat, 29 Aug 2020 16:06:19 -0500 Subject: Everyone ping: Send embed on ping, fixed check When a user pings the everyone role, they now get an embed explaining why what they did was wrong. The ping detection was also fixed to not thing that every message was a ping (changed form `>=` to `>`). --- bot/rules/everyone_ping.py | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index bfc400831..65ee1062c 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -1,6 +1,14 @@ +import logging +import textwrap from typing import Dict, Iterable, List, Optional, Tuple -from discord import Member, Message +from discord import Embed, Member, Message + +from bot.cogs.moderation.utils import send_private_embed +from bot.constants import Colours + +# For embed sender +log = logging.getLogger(__name__) async def apply( @@ -15,10 +23,28 @@ async def apply( ev_msgs_ct = 0 for msg in relevant_messages: - if '@everyone' in msg.content: + if "@everyone" in msg.content: ev_msgs_ct += 1 - if ev_msgs_ct >= config['max']: + if ev_msgs_ct > config["max"]: + # Send the user an embed giving them more info: + member_count = "{:,}".format(last_message.guild.member_count).split( + "," + )[0] + embed_text = textwrap.dedent( + f""" + Hello {last_message.author.display_name}, please don't try to ping {member_count}k people. + **It will not have good results.** + If you want to know what it would be like, imagine pinging Greenland. Please don't ping Greenland. + """ + ) + print(embed_text) + embed = Embed( + title="Everyone Ping Mute Info", + colour=Colours.soft_red, + description=embed_text, + ) + await send_private_embed(last_message.author, embed) return ( f"pinged the everyone role {ev_msgs_ct} times in {config['interval']}s", (last_message.author,), -- cgit v1.2.3 From 218e50ce41dea40ec04614db1888cd44db7843b5 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sat, 29 Aug 2020 17:09:24 -0500 Subject: Everyone Ping: Removed debug `print`, spelling Removed a debug `print` statement, fixed a spelling mistake. Also added a comment for the DM string. --- bot/rules/everyone_ping.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 65ee1062c..b99e75059 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -31,14 +31,14 @@ async def apply( member_count = "{:,}".format(last_message.guild.member_count).split( "," )[0] + # Change the `K` to an `M` once the server reaches over 1 million people. embed_text = textwrap.dedent( f""" - Hello {last_message.author.display_name}, please don't try to ping {member_count}k people. + Hello {last_message.author.display_name}, please don't try to ping {member_count}K people. **It will not have good results.** If you want to know what it would be like, imagine pinging Greenland. Please don't ping Greenland. """ ) - print(embed_text) embed = Embed( title="Everyone Ping Mute Info", colour=Colours.soft_red, -- cgit v1.2.3 From e42db79c2fd7be4b0c82a5ba4e3f1ca4349745a2 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sat, 29 Aug 2020 18:27:07 -0500 Subject: Everyone Ping: Changed embed text and location The you can view the embed text in the `everyone_ping.py` file. The embed also now sends in the server instead of a DM. --- bot/rules/everyone_ping.py | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index b99e75059..47931caae 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -4,7 +4,6 @@ from typing import Dict, Iterable, List, Optional, Tuple from discord import Embed, Member, Message -from bot.cogs.moderation.utils import send_private_embed from bot.constants import Colours # For embed sender @@ -17,9 +16,7 @@ async def apply( config: Dict[str, int], ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: """Detects if a user has sent an '@everyone' ping.""" - relevant_messages = tuple( - msg for msg in recent_messages if msg.author == last_message.author - ) + relevant_messages = tuple(msg for msg in recent_messages if msg.author == last_message.author) ev_msgs_ct = 0 for msg in relevant_messages: @@ -28,23 +25,16 @@ async def apply( if ev_msgs_ct > config["max"]: # Send the user an embed giving them more info: - member_count = "{:,}".format(last_message.guild.member_count).split( - "," - )[0] + member_count = "{:,}".format(last_message.guild.member_count).split(",")[0] # Change the `K` to an `M` once the server reaches over 1 million people. embed_text = textwrap.dedent( f""" - Hello {last_message.author.display_name}, please don't try to ping {member_count}K people. + Please don't try to ping {member_count}K people. **It will not have good results.** - If you want to know what it would be like, imagine pinging Greenland. Please don't ping Greenland. """ ) - embed = Embed( - title="Everyone Ping Mute Info", - colour=Colours.soft_red, - description=embed_text, - ) - await send_private_embed(last_message.author, embed) + embed = Embed(description=embed_text, colour=Colours.soft_red) + await last_message.channel.send(f"Hey {last_message.author.mention}!", embed=embed) return ( f"pinged the everyone role {ev_msgs_ct} times in {config['interval']}s", (last_message.author,), -- cgit v1.2.3 From dbb05d95420cdd6ff08231ce7b9c67cc46bf3675 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sat, 29 Aug 2020 18:49:02 -0500 Subject: Everyone Ping: Fixed linting error Switched from string.format to f-string for server member count. --- bot/rules/everyone_ping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 47931caae..8c1b43628 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -25,7 +25,7 @@ async def apply( if ev_msgs_ct > config["max"]: # Send the user an embed giving them more info: - member_count = "{:,}".format(last_message.guild.member_count).split(",")[0] + member_count = f'{last_message.guild.member_count}'.split(",")[0] # Change the `K` to an `M` once the server reaches over 1 million people. embed_text = textwrap.dedent( f""" -- cgit v1.2.3 From 20f0dfd57f3ed711ef46169b9dcf0e8ee57bcfd1 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sun, 30 Aug 2020 07:32:44 -0500 Subject: Everyone ping: Changed message, cleaned file Changed the message to say the raw member count, not just thousands. Also cleaned up some unused variables and imports in the file. --- bot/rules/everyone_ping.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 8c1b43628..037d7254e 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -1,4 +1,3 @@ -import logging import textwrap from typing import Dict, Iterable, List, Optional, Tuple @@ -6,9 +5,6 @@ from discord import Embed, Member, Message from bot.constants import Colours -# For embed sender -log = logging.getLogger(__name__) - async def apply( last_message: Message, @@ -25,11 +21,9 @@ async def apply( if ev_msgs_ct > config["max"]: # Send the user an embed giving them more info: - member_count = f'{last_message.guild.member_count}'.split(",")[0] - # Change the `K` to an `M` once the server reaches over 1 million people. embed_text = textwrap.dedent( f""" - Please don't try to ping {member_count}K people. + Please don't try to ping {last_message.guild.member_count} people. **It will not have good results.** """ ) -- cgit v1.2.3 From e7862878bd4233cc9340c00bfb77079c318a0b22 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sun, 30 Aug 2020 07:37:57 -0500 Subject: Everyone ping: added formatting to member count Seperated the member count by commas every three digits. --- bot/rules/everyone_ping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 037d7254e..44e9aade4 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -23,7 +23,7 @@ async def apply( # Send the user an embed giving them more info: embed_text = textwrap.dedent( f""" - Please don't try to ping {last_message.guild.member_count} people. + Please don't try to ping {last_message.guild.member_count:,} people. **It will not have good results.** """ ) -- cgit v1.2.3 From 702ff7e80d859dbc8189e55d1dcf9e3bd5959c7a Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sun, 30 Aug 2020 08:18:52 -0500 Subject: Everyone Ping: PR Review Changed cryptic variable name. Changed ping response to use `bot.constants.NEGATIVE_REPLIES`. Changed ping repsonse to only ping user once. --- bot/rules/everyone_ping.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 44e9aade4..f3790ba2c 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -1,9 +1,10 @@ +import random import textwrap from typing import Dict, Iterable, List, Optional, Tuple from discord import Embed, Member, Message -from bot.constants import Colours +from bot.constants import Colours, NEGATIVE_REPLIES async def apply( @@ -14,23 +15,27 @@ async def apply( """Detects if a user has sent an '@everyone' ping.""" relevant_messages = tuple(msg for msg in recent_messages if msg.author == last_message.author) - ev_msgs_ct = 0 + everyone_messages_count = 0 for msg in relevant_messages: if "@everyone" in msg.content: - ev_msgs_ct += 1 + everyone_messages_count += 1 - if ev_msgs_ct > config["max"]: + if everyone_messages_count > config["max"]: # Send the user an embed giving them more info: embed_text = textwrap.dedent( f""" + **{random.choice(NEGATIVE_REPLIES)}** Please don't try to ping {last_message.guild.member_count:,} people. - **It will not have good results.** - """ + """ ) + + # Make embed: embed = Embed(description=embed_text, colour=Colours.soft_red) - await last_message.channel.send(f"Hey {last_message.author.mention}!", embed=embed) + + # Send embed: + await last_message.channel.send(embed=embed) return ( - f"pinged the everyone role {ev_msgs_ct} times in {config['interval']}s", + f"pinged the everyone role {everyone_messages_count} times in {config['interval']}s", (last_message.author,), relevant_messages, ) -- cgit v1.2.3 From 94b89a867942d98138f43ec8d2e6bf8f6607c240 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sun, 30 Aug 2020 09:52:17 -0500 Subject: Everyone Ping: NEGATIVE_REPLIES in title The NEGATIVE_REPLIES header is now the title of the embed. --- bot/rules/everyone_ping.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index f3790ba2c..08415b1e0 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -1,5 +1,4 @@ import random -import textwrap from typing import Dict, Iterable, List, Optional, Tuple from discord import Embed, Member, Message @@ -22,15 +21,10 @@ async def apply( if everyone_messages_count > config["max"]: # Send the user an embed giving them more info: - embed_text = textwrap.dedent( - f""" - **{random.choice(NEGATIVE_REPLIES)}** - Please don't try to ping {last_message.guild.member_count:,} people. - """ - ) + embed_text = f"Please don't try to ping {last_message.guild.member_count:,} people." # Make embed: - embed = Embed(description=embed_text, colour=Colours.soft_red) + embed = Embed(title=random.choice(NEGATIVE_REPLIES), description=embed_text, colour=Colours.soft_red) # Send embed: await last_message.channel.send(embed=embed) -- cgit v1.2.3 From a61d0321c4b7a6e137ccb59d8a3af0428838778c Mon Sep 17 00:00:00 2001 From: Dennis Pham Date: Sun, 30 Aug 2020 17:35:10 -0400 Subject: Allow moderators to use defcon --- bot/cogs/defcon.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index de0f4545e..9087ac454 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -10,7 +10,7 @@ from discord.ext.commands import Cog, Context, group from bot.bot import Bot from bot.cogs.moderation import ModLog -from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles +from bot.constants import Channels, Colours, Emojis, Event, Icons, MODERATION_ROLES, Roles from bot.decorators import with_role log = logging.getLogger(__name__) @@ -119,7 +119,7 @@ class Defcon(Cog): ) @group(name='defcon', aliases=('dc',), invoke_without_command=True) - @with_role(Roles.admins, Roles.owners) + @with_role(*MODERATION_ROLES) async def defcon_group(self, ctx: Context) -> None: """Check the DEFCON status or run a subcommand.""" await ctx.send_help(ctx.command) @@ -163,7 +163,7 @@ class Defcon(Cog): self.bot.stats.gauge("defcon.threshold", days) @defcon_group.command(name='enable', aliases=('on', 'e'), root_aliases=("defon",)) - @with_role(Roles.admins, Roles.owners) + @with_role(*MODERATION_ROLES) async def enable_command(self, ctx: Context) -> None: """ Enable DEFCON mode. Useful in a pinch, but be sure you know what you're doing! @@ -176,7 +176,7 @@ class Defcon(Cog): await self.update_channel_topic() @defcon_group.command(name='disable', aliases=('off', 'd'), root_aliases=("defoff",)) - @with_role(Roles.admins, Roles.owners) + @with_role(*MODERATION_ROLES) async def disable_command(self, ctx: Context) -> None: """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!""" self.enabled = False @@ -184,7 +184,7 @@ class Defcon(Cog): await self.update_channel_topic() @defcon_group.command(name='status', aliases=('s',)) - @with_role(Roles.admins, Roles.owners) + @with_role(*MODERATION_ROLES) async def status_command(self, ctx: Context) -> None: """Check the current status of DEFCON mode.""" embed = Embed( @@ -196,7 +196,7 @@ class Defcon(Cog): await ctx.send(embed=embed) @defcon_group.command(name='days') - @with_role(Roles.admins, Roles.owners) + @with_role(*MODERATION_ROLES) async def days_command(self, ctx: Context, days: int) -> None: """Set how old an account must be to join the server, in days, with DEFCON mode enabled.""" self.days = timedelta(days=days) -- cgit v1.2.3 From 94dcfa584599301f0cfb9e47d4ef9f7f40bdc23c Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sun, 30 Aug 2020 21:55:39 -0500 Subject: Everyone Ping: PR Review 2 Removed redundant comments. Switched to regex to avoid punishing users for putting `@everyone` in codeblocks. Changed log message since this isn't a anti-spam rule based off of frequency. Added check for `<@&{guild_id}>` ping, also checks for codeblocks. --- bot/rules/everyone_ping.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 08415b1e0..3a8174e44 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -1,9 +1,15 @@ import random +import re from typing import Dict, Iterable, List, Optional, Tuple from discord import Embed, Member, Message -from bot.constants import Colours, NEGATIVE_REPLIES +from bot.constants import Colours, Guild, NEGATIVE_REPLIES + +# Generate regex for checking for pings: +guild_id = Guild.id +EVERYONE_RE_INLINE_CODE = re.compile(rf"(?!`)@everyone(?!`)|(?!`)<@&{guild_id}>(?!`)") +EVERYONE_RE_MULTILINE_CODE = re.compile(rf"(?!```\n.*)@everyone(?!\n.*```)|(?!```\n.*)<@&{guild_id}>(?!\n.*```)") async def apply( @@ -16,20 +22,19 @@ async def apply( everyone_messages_count = 0 for msg in relevant_messages: - if "@everyone" in msg.content: + num_everyone_pings_inline = len(re.findall(EVERYONE_RE_INLINE_CODE, msg.content)) + num_everyone_pings_multiline = len(re.findall(EVERYONE_RE_MULTILINE_CODE, msg.content)) + if num_everyone_pings_inline and num_everyone_pings_multiline: everyone_messages_count += 1 if everyone_messages_count > config["max"]: - # Send the user an embed giving them more info: + # Send the channel an embed giving the user more info: embed_text = f"Please don't try to ping {last_message.guild.member_count:,} people." - - # Make embed: embed = Embed(title=random.choice(NEGATIVE_REPLIES), description=embed_text, colour=Colours.soft_red) - - # Send embed: await last_message.channel.send(embed=embed) + return ( - f"pinged the everyone role {everyone_messages_count} times in {config['interval']}s", + "pinged the everyone role", (last_message.author,), relevant_messages, ) -- cgit v1.2.3 From 9c52a99a03777cdfd728f354cdb305398791eac1 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Mon, 31 Aug 2020 08:17:57 -0500 Subject: Everyone Ping: Regex Fix Changed the regex to not punish users who have text other than `@everyone` in their codeblocks. Multiline codeblocks can now have `@everyone` in them. --- bot/rules/everyone_ping.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 3a8174e44..560a9ec14 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -8,8 +8,8 @@ from bot.constants import Colours, Guild, NEGATIVE_REPLIES # Generate regex for checking for pings: guild_id = Guild.id -EVERYONE_RE_INLINE_CODE = re.compile(rf"(?!`)@everyone(?!`)|(?!`)<@&{guild_id}>(?!`)") -EVERYONE_RE_MULTILINE_CODE = re.compile(rf"(?!```\n.*)@everyone(?!\n.*```)|(?!```\n.*)<@&{guild_id}>(?!\n.*```)") +EVERYONE_RE_INLINE_CODE = re.compile(rf"^(?!`)@everyone(?!`)$|^(?!`)<@&{guild_id}>(?!`)$") +EVERYONE_RE_MULTILINE_CODE = re.compile(rf"^(?!```)@everyone(?!```)$|^(?!```)<@&{guild_id}>(?!```)$") async def apply( -- cgit v1.2.3 From 4906406cedaa476b8eb1665bc0e20616c91d7f6b Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Mon, 31 Aug 2020 19:19:57 -0500 Subject: Everyone Ping: Fixed regex to catch *all* pings --- bot/rules/everyone_ping.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 560a9ec14..89d9fe570 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -8,8 +8,8 @@ from bot.constants import Colours, Guild, NEGATIVE_REPLIES # Generate regex for checking for pings: guild_id = Guild.id -EVERYONE_RE_INLINE_CODE = re.compile(rf"^(?!`)@everyone(?!`)$|^(?!`)<@&{guild_id}>(?!`)$") -EVERYONE_RE_MULTILINE_CODE = re.compile(rf"^(?!```)@everyone(?!```)$|^(?!```)<@&{guild_id}>(?!```)$") +EVERYONE_RE_INLINE_CODE = re.compile(rf"^(?!`).*@everyone.*(?!`)$|^(?!`).*<@&{guild_id}>.*(?!`)$") +EVERYONE_RE_MULTILINE_CODE = re.compile(rf"^(?!```).*@everyone.*(?!```)$|^(?!```).*<@&{guild_id}>.*(?!```)$") async def apply( -- cgit v1.2.3 From 9bf8e5e394b5f9a8735f235cafe0fd2526be6ab2 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Mon, 31 Aug 2020 19:49:38 -0700 Subject: Removed image pagination utility. --- bot/pagination.py | 164 ------------------------------------------------------ 1 file changed, 164 deletions(-) diff --git a/bot/pagination.py b/bot/pagination.py index bab98cacf..182b2fa76 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -374,167 +374,3 @@ class LinePaginator(Paginator): log.debug("Ending pagination and clearing reactions.") with suppress(discord.NotFound): await message.clear_reactions() - - -class ImagePaginator(Paginator): - """ - Helper class that paginates images for embeds in messages. - - Close resemblance to LinePaginator, except focuses on images over text. - - Refer to ImagePaginator.paginate for documentation on how to use. - """ - - def __init__(self, prefix: str = "", suffix: str = ""): - super().__init__(prefix, suffix) - self._current_page = [prefix] - self.images = [] - self._pages = [] - self._count = 0 - - def add_line(self, line: str = '', *, empty: bool = False) -> None: - """Adds a line to each page.""" - if line: - self._count = len(line) - else: - self._count = 0 - self._current_page.append(line) - self.close_page() - - def add_image(self, image: str = None) -> None: - """Adds an image to a page.""" - self.images.append(image) - - @classmethod - async def paginate( - cls, - pages: t.List[t.Tuple[str, str]], - ctx: Context, embed: discord.Embed, - prefix: str = "", - suffix: str = "", - timeout: int = 300, - exception_on_empty_embed: bool = False - ) -> t.Optional[discord.Message]: - """ - Use a paginator and set of reactions to provide pagination over a set of title/image pairs. - - The reactions are used to switch page, or to finish with pagination. - - When used, this will send a message using `ctx.send()` and apply a set of reactions to it. These reactions may - be used to change page, or to remove pagination from the message. - - Note: Pagination will be removed automatically if no reaction is added for five minutes (300 seconds). - - Example: - >>> embed = discord.Embed() - >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) - >>> await ImagePaginator.paginate(pages, ctx, embed) - """ - def check_event(reaction_: discord.Reaction, member: discord.Member) -> bool: - """Checks each reaction added, if it matches our conditions pass the wait_for.""" - return all(( - # Reaction is on the same message sent - reaction_.message.id == message.id, - # The reaction is part of the navigation menu - str(reaction_.emoji) in PAGINATION_EMOJI, - # The reactor is not a bot - not member.bot - )) - - paginator = cls(prefix=prefix, suffix=suffix) - current_page = 0 - - if not pages: - if exception_on_empty_embed: - log.exception("Pagination asked for empty image list") - raise EmptyPaginatorEmbed("No images to paginate") - - log.debug("No images to add to paginator, adding '(no images to display)' message") - pages.append(("(no images to display)", "")) - - for text, image_url in pages: - paginator.add_line(text) - paginator.add_image(image_url) - - embed.description = paginator.pages[current_page] - image = paginator.images[current_page] - - if image: - embed.set_image(url=image) - - if len(paginator.pages) <= 1: - return await ctx.send(embed=embed) - - embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") - message = await ctx.send(embed=embed) - - for emoji in PAGINATION_EMOJI: - await message.add_reaction(emoji) - - while True: - # Start waiting for reactions - try: - reaction, user = await ctx.bot.wait_for("reaction_add", timeout=timeout, check=check_event) - except asyncio.TimeoutError: - log.debug("Timed out waiting for a reaction") - break # We're done, no reactions for the last 5 minutes - - # Deletes the users reaction - await message.remove_reaction(reaction.emoji, user) - - # Delete reaction press - [:trashcan:] - if str(reaction.emoji) == DELETE_EMOJI: - log.debug("Got delete reaction") - return await message.delete() - - # First reaction press - [:track_previous:] - if reaction.emoji == FIRST_EMOJI: - if current_page == 0: - log.debug("Got first page reaction, but we're on the first page - ignoring") - continue - - current_page = 0 - reaction_type = "first" - - # Last reaction press - [:track_next:] - if reaction.emoji == LAST_EMOJI: - if current_page >= len(paginator.pages) - 1: - log.debug("Got last page reaction, but we're on the last page - ignoring") - continue - - current_page = len(paginator.pages) - 1 - reaction_type = "last" - - # Previous reaction press - [:arrow_left: ] - if reaction.emoji == LEFT_EMOJI: - if current_page <= 0: - log.debug("Got previous page reaction, but we're on the first page - ignoring") - continue - - current_page -= 1 - reaction_type = "previous" - - # Next reaction press - [:arrow_right:] - if reaction.emoji == RIGHT_EMOJI: - if current_page >= len(paginator.pages) - 1: - log.debug("Got next page reaction, but we're on the last page - ignoring") - continue - - current_page += 1 - reaction_type = "next" - - # Magic happens here, after page and reaction_type is set - embed.description = paginator.pages[current_page] - - image = paginator.images[current_page] - if image: - embed.set_image(url=image) - - embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") - log.debug(f"Got {reaction_type} page reaction - changing to page {current_page + 1}/{len(paginator.pages)}") - - await message.edit(embed=embed) - - log.debug("Ending pagination and clearing reactions.") - with suppress(discord.NotFound): - await message.clear_reactions() -- cgit v1.2.3 From b7644aa822def549e2591b53c69af3cf44355ac9 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Mon, 31 Aug 2020 19:56:24 -0700 Subject: Removed ImagePaginator testing. --- tests/bot/test_pagination.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/tests/bot/test_pagination.py b/tests/bot/test_pagination.py index ce880d457..630f2516d 100644 --- a/tests/bot/test_pagination.py +++ b/tests/bot/test_pagination.py @@ -44,18 +44,3 @@ class LinePaginatorTests(TestCase): self.paginator.add_line('x' * (self.paginator.scale_to_size + 1)) # Note: item at index 1 is the truncated line, index 0 is prefix self.assertEqual(self.paginator._current_page[1], 'x' * self.paginator.scale_to_size) - - -class ImagePaginatorTests(TestCase): - """Tests functionality of the `ImagePaginator`.""" - - def setUp(self): - """Create a paginator for the test method.""" - self.paginator = pagination.ImagePaginator() - - def test_add_image_appends_image(self): - """`add_image` appends the image to the image list.""" - image = 'lemon' - self.paginator.add_image(image) - - assert self.paginator.images == [image] -- cgit v1.2.3 From 10181ab4dc8711c561caca0a2fbc40ff4c4ecf6c Mon Sep 17 00:00:00 2001 From: Xithrius Date: Mon, 31 Aug 2020 20:40:45 -0700 Subject: Removed loading of the Wolfram cog. --- bot/__main__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/__main__.py b/bot/__main__.py index f698b5662..fe2cf90e6 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -74,7 +74,6 @@ bot.load_extension("bot.cogs.token_remover") bot.load_extension("bot.cogs.utils") bot.load_extension("bot.cogs.watchchannels") bot.load_extension("bot.cogs.webhook_remover") -bot.load_extension("bot.cogs.wolfram") if constants.HelpChannels.enable: bot.load_extension("bot.cogs.help_channels") -- cgit v1.2.3 From 03ab17b9383a57591b2f82a0526188efd902f61b Mon Sep 17 00:00:00 2001 From: wookie184 Date: Tue, 1 Sep 2020 11:27:29 +0100 Subject: Added checks to ignore webhook and bot messages --- bot/cogs/antimalware.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index c76bd2c60..7894ec48f 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -55,6 +55,10 @@ class AntiMalware(Cog): if not message.attachments or not message.guild: return + # Ignore webhook and bot messages + if message.webhook_id or message.author.bot: + return + # Check if user is staff, if is, return # Since we only care that roles exist to iterate over, check for the attr rather than a User/Member instance if hasattr(message.author, "roles") and any(role.id in STAFF_ROLES for role in message.author.roles): -- cgit v1.2.3 From 1a47f5d80f2f91c3da5a9626e9a6694381d49cd0 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Tue, 1 Sep 2020 12:22:43 +0100 Subject: Fixed old tests and added 2 new ones --- tests/bot/cogs/test_antimalware.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/bot/cogs/test_antimalware.py b/tests/bot/cogs/test_antimalware.py index ecb7abf00..f50c0492d 100644 --- a/tests/bot/cogs/test_antimalware.py +++ b/tests/bot/cogs/test_antimalware.py @@ -23,6 +23,8 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): } self.cog = antimalware.AntiMalware(self.bot) self.message = MockMessage() + self.message.webhook_id = None + self.message.author.bot = None self.whitelist = [".first", ".second", ".third"] async def test_message_with_allowed_attachment(self): @@ -48,6 +50,26 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): self.message.delete.assert_not_called() + async def test_webhook_message_with_illegal_extension(self): + """A webhook message containing an illegal extension should be ignored.""" + attachment = MockAttachment(filename="python.disallowed") + self.message.webhook_id = 697140105563078727 + self.message.attachments = [attachment] + + await self.cog.on_message(self.message) + + self.message.delete.assert_not_called() + + async def test_bot_message_with_illegal_extension(self): + """A bot message containing an illegal extension should be ignored.""" + attachment = MockAttachment(filename="python.disallowed") + self.message.author.bot = 409107086526644234 + self.message.attachments = [attachment] + + await self.cog.on_message(self.message) + + self.message.delete.assert_not_called() + async def test_message_with_illegal_extension_gets_deleted(self): """A message containing an illegal extension should send an embed.""" attachment = MockAttachment(filename="python.disallowed") -- cgit v1.2.3 From 1512dcc994dfacd0995a93320efc001550f15212 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Fri, 4 Sep 2020 20:05:03 +0200 Subject: Disable burst_shared filter of the AntiSpam cog Our AntiSpam cog suffers from a race condition that causes it to try and infract the same user multiple times. As that happens frequently with the burst_shared filter, it means that our bot joins in and starts spamming the channel with error messages. Another issue is that burst_shared may cause our bot to send a lot of DMs to a lot of different members. This caused our bot to get a DM ban from Discord after a recent `everyone` ping incident. I've decided to disable the `burst_shared` filter by commenting out the relevant lines but leave the code in place otherwise. This means we still have the implementation handy in case we want to re-enable it on short notice. Signed-off-by: Sebastiaan Zeeff --- bot/cogs/antispam.py | 3 ++- config-default.yml | 10 +++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index d003f962b..b8939113f 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -27,7 +27,8 @@ log = logging.getLogger(__name__) RULE_FUNCTION_MAPPING = { 'attachments': rules.apply_attachments, 'burst': rules.apply_burst, - 'burst_shared': rules.apply_burst_shared, + # burst shared is temporarily disabled due to a bug + # 'burst_shared': rules.apply_burst_shared, 'chars': rules.apply_chars, 'discord_emojis': rules.apply_discord_emojis, 'duplicates': rules.apply_duplicates, diff --git a/config-default.yml b/config-default.yml index 766f7050c..e9324c62f 100644 --- a/config-default.yml +++ b/config-default.yml @@ -352,9 +352,13 @@ anti_spam: interval: 10 max: 7 - burst_shared: - interval: 10 - max: 20 + # Burst shared it (temporarily) disabled to prevent + # the bug that triggers multiple infractions/DMs per + # user. It also tends to catch a lot of innocent users + # now that we're so big. + # burst_shared: + # interval: 10 + # max: 20 chars: interval: 5 -- cgit v1.2.3 From d2e7dd3763d24a2224fe0eefd78852e2a2389850 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Fri, 4 Sep 2020 20:25:26 +0200 Subject: Move bolding markdown outside of text link. On some devices the markdown gets rendered improperly, leaving the asterisks in the message without bolding. --- 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 541c6f336..0f9cac89e 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -36,7 +36,7 @@ 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}). +check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**. """ DORMANT_MSG = f""" @@ -47,7 +47,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})**. """ CoroutineFunc = t.Callable[..., t.Coroutine] -- cgit v1.2.3 From 53cb77bb2d541d0be61bc3c25e37b54601963b7c Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sat, 5 Sep 2020 11:07:58 +0200 Subject: Disable everyone_ping filter in AntiSpam cog As there are a few bugs in the implementation, I've temporarily disabled the at-everyone ping filter in the AntiSpam cog. We can disable it after we've fixed the bugs. Signed-off-by: Sebastiaan Zeeff --- bot/cogs/antispam.py | 4 +++- config-default.yml | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index b8939113f..3ad487d8c 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -36,7 +36,9 @@ RULE_FUNCTION_MAPPING = { 'mentions': rules.apply_mentions, 'newlines': rules.apply_newlines, 'role_mentions': rules.apply_role_mentions, - 'everyone_ping': rules.apply_everyone_ping, + # the everyone filter is temporarily disabled until + # it has been improved. + # 'everyone_ping': rules.apply_everyone_ping, } diff --git a/config-default.yml b/config-default.yml index e9324c62f..6e7cff92d 100644 --- a/config-default.yml +++ b/config-default.yml @@ -389,9 +389,11 @@ anti_spam: interval: 10 max: 3 - everyone_ping: - interval: 10 - max: 0 + # The everyone ping filter is temporarily disabled + # until we've fixed a couple of bugs. + # everyone_ping: + # interval: 10 + # max: 0 reddit: -- cgit v1.2.3 From 1b3a5c5d90bb658895fea8a2bed91366d2f2f76e Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 10 Sep 2020 18:41:07 +0200 Subject: Verification: move constants to config --- bot/cogs/verification.py | 45 ++++++++++++++++++++------------------------- bot/constants.py | 10 ++++++++++ config-default.yml | 13 +++++++++++++ 3 files changed, 43 insertions(+), 25 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 08f7c282e..0092a0898 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -18,16 +18,6 @@ from bot.utils.redis_cache import RedisCache log = logging.getLogger(__name__) -UNVERIFIED_AFTER = 3 # Amount of days after which non-Developers receive the @Unverified role -KICKED_AFTER = 30 # Amount of days after which non-Developers get kicked from the guild - -# Number in range [0, 1] determining the percentage of unverified users that are safe -# to be kicked from the guild in one batch, any larger amount will require staff confirmation, -# set this to 0 to require explicit approval for batches of any size -KICK_CONFIRMATION_THRESHOLD = 0.01 # 1% - -BOT_MESSAGE_DELETE_DELAY = 10 - # Sent via DMs once user joins the guild ON_JOIN_MESSAGE = f""" Hello! Welcome to Python Discord! @@ -62,7 +52,7 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! # Sent via DMs to users kicked for failing to verify KICKED_MESSAGE = f""" Hi! You have been automatically kicked from Python Discord as you have failed to accept our rules \ -within `{KICKED_AFTER}` days. If this was an accident, please feel free to join us again! +within `{constants.Verification.kicked_after}` days. If this was an accident, please feel free to join us again! {constants.Guild.invite} """ @@ -74,11 +64,9 @@ REMINDER_MESSAGE = f""" Welcome to Python Discord! Please read the documents mentioned above and type `!accept` to gain permissions \ to send messages in the community! -You will be kicked if you don't verify within `{KICKED_AFTER}` days. +You will be kicked if you don't verify within `{constants.Verification.kicked_after}` days. """.strip() -REMINDER_FREQUENCY = 28 # Hours to wait between sending `REMINDER_MESSAGE` - # An async function taking a Member param Request = t.Callable[[discord.Member], t.Awaitable] @@ -209,7 +197,7 @@ class Verification(Cog): pydis = self.bot.get_guild(constants.Guild.id) percentage = n_members / len(pydis.members) - if percentage < KICK_CONFIRMATION_THRESHOLD: + if percentage < constants.Verification.kick_confirmation_threshold: log.debug(f"Kicking {percentage:.2%} of the guild's population is seen as safe") return True @@ -221,7 +209,8 @@ class Verification(Cog): confirmation_msg = await core_dev_channel.send( f"{core_dev_ping} Verification determined that `{n_members}` members should be kicked as they haven't " - f"verified in `{KICKED_AFTER}` days. This is `{percentage:.2%}` of the guild's population. Proceed?", + f"verified in `{constants.Verification.kicked_after}` days. This is `{percentage:.2%}` of the guild's " + f"population. Proceed?", allowed_mentions=mention_role(constants.Roles.core_developers), ) @@ -333,7 +322,7 @@ class Verification(Cog): Note that this is a potentially destructive operation. Returns the amount of successful requests. """ - log.info(f"Kicking {len(members)} members from the guild (not verified after {KICKED_AFTER} days)") + log.info(f"Kicking {len(members)} members (not verified after {constants.Verification.kicked_after} days)") async def kick_request(member: discord.Member) -> None: """Send `KICKED_MESSAGE` to `member` and kick them from the guild.""" @@ -343,7 +332,7 @@ class Verification(Cog): log.trace(f"DM dispatch failed on 403 error with code: {exc_403.code}") if exc_403.code != 50_007: # 403 raised for any other reason than disabled DMs raise StopExecution(reason=exc_403) - await member.kick(reason=f"User has not verified in {KICKED_AFTER} days") + await member.kick(reason=f"User has not verified in {constants.Verification.kicked_after} days") n_kicked = await self._send_requests(members, kick_request, Limit(batch_size=2, sleep_secs=1)) self.bot.stats.incr("verification.kicked", count=n_kicked) @@ -358,11 +347,14 @@ class Verification(Cog): Returns the amount of successful requests. """ - log.info(f"Assigning {role} role to {len(members)} members (not verified after {UNVERIFIED_AFTER} days)") + log.info( + f"Assigning {role} role to {len(members)} members (not verified " + f"after {constants.Verification.unverified_after} days)" + ) async def role_request(member: discord.Member) -> None: """Add `role` to `member`.""" - await member.add_roles(role, reason=f"User has not verified in {UNVERIFIED_AFTER} days") + await member.add_roles(role, reason=f"Not verified after {constants.Verification.unverified_after} days") return await self._send_requests(members, role_request, Limit(batch_size=25, sleep_secs=1)) @@ -397,10 +389,13 @@ class Verification(Cog): # to do with them based on time passed since their join date since_join = current_dt - member.joined_at - if since_join > timedelta(days=KICKED_AFTER): + if since_join > timedelta(days=constants.Verification.kicked_after): for_kick.add(member) # User should be removed from the guild - elif since_join > timedelta(days=UNVERIFIED_AFTER) and unverified not in member.roles: + elif ( + since_join > timedelta(days=constants.Verification.unverified_after) + and unverified not in member.roles + ): for_role.add(member) # User should be given the @Unverified role log.debug(f"Found {len(for_role)} users for {unverified} role, {len(for_kick)} users to be kicked") @@ -445,7 +440,7 @@ class Verification(Cog): # endregion # region: periodically ping @Unverified - @tasks.loop(hours=REMINDER_FREQUENCY) + @tasks.loop(hours=constants.Verification.reminder_frequency) async def ping_unverified(self) -> None: """ Delete latest `REMINDER_MESSAGE` and send it again. @@ -488,7 +483,7 @@ class Verification(Cog): time_since = datetime.utcnow() - snowflake_time(last_reminder) log.trace(f"Time since latest verification reminder: {time_since}") - to_sleep = timedelta(hours=REMINDER_FREQUENCY) - time_since + to_sleep = timedelta(hours=constants.Verification.reminder_frequency) - time_since log.trace(f"Time to sleep until next ping: {to_sleep}") # Delta can be negative if `REMINDER_FREQUENCY` has already passed @@ -519,7 +514,7 @@ class Verification(Cog): if message.author.bot: # They're a bot, delete their message after the delay. - await message.delete(delay=BOT_MESSAGE_DELETE_DELAY) + await message.delete(delay=constants.Verification.bot_message_delete_delay) return # if a user mentions a role or guild member diff --git a/bot/constants.py b/bot/constants.py index daef6c095..820828a19 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -589,6 +589,16 @@ class PythonNews(metaclass=YAMLGetter): webhook: int +class Verification(metaclass=YAMLGetter): + section = "verification" + + unverified_after: int + kicked_after: int + reminder_frequency: int + bot_message_delete_delay: int + kick_confirmation_threshold: float + + class Event(Enum): """ Event names. This does not include every event (for example, raw diff --git a/config-default.yml b/config-default.yml index a98fd14ef..c89695bd9 100644 --- a/config-default.yml +++ b/config-default.yml @@ -493,5 +493,18 @@ python_news: channel: *PYNEWS_CHANNEL webhook: *PYNEWS_WEBHOOK + +verification: + unverified_after: 3 # Days after which non-Developers receive the @Unverified role + kicked_after: 30 # Days after which non-Developers get kicked from the guild + reminder_frequency: 28 # Hours between @Unverified pings + bot_message_delete_delay: 10 # Seconds before deleting bots response in #verification + + # Number in range [0, 1] determining the percentage of unverified users that are safe + # to be kicked from the guild in one batch, any larger amount will require staff confirmation, + # set this to 0 to require explicit approval for batches of any size + kick_confirmation_threshold: 0.01 # 1% + + config: required_keys: ['bot.token'] -- cgit v1.2.3 From 7ebc481c32cf9b7f0ee1124b22e4ed8c68de9386 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 11 Sep 2020 19:46:48 +0200 Subject: Verification: update & improve docstrings After moving constants to config, the docstring references were not updated accordingly, and remained uppercase. This commit also removed the redundant list indentation. --- bot/cogs/verification.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 0092a0898..9ae92a228 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -111,12 +111,11 @@ class Verification(Cog): There are two internal tasks in this cog: - * `update_unverified_members` - * Unverified members are given the @Unverified role after `UNVERIFIED_AFTER` days - * Unverified members are kicked after `UNVERIFIED_AFTER` days - - * `ping_unverified` - * Periodically ping the @Unverified role in the verification channel + * `update_unverified_members` + * Unverified members are given the @Unverified role after configured `unverified_after` days + * Unverified members are kicked after configured `kicked_after` days + * `ping_unverified` + * Periodically ping the @Unverified role in the verification channel Statistics are collected in the 'verification.' namespace. @@ -188,8 +187,8 @@ class Verification(Cog): Determine whether `n_members` is a reasonable amount of members to kick. First, `n_members` is checked against the size of the PyDis guild. If `n_members` are - more than `KICK_CONFIRMATION_THRESHOLD` of the guild, the operation must be confirmed - by staff in #core-dev. Otherwise, the operation is seen as safe. + more than the configured `kick_confirmation_threshold` of the guild, the operation + must be confirmed by staff in #core-dev. Otherwise, the operation is seen as safe. """ log.debug(f"Checking whether {n_members} members are safe to kick") @@ -363,8 +362,8 @@ class Verification(Cog): Check in on the verification status of PyDis members. This coroutine finds two sets of users: - * Not verified after `UNVERIFIED_AFTER` days, should be given the @Unverified role - * Not verified after `KICKED_AFTER` days, should be kicked from the guild + * Not verified after configured `unverified_after` days, should be given the @Unverified role + * Not verified after configured `kicked_after` days, should be kicked from the guild These sets are always disjoint, i.e. share no common members. """ @@ -471,7 +470,7 @@ class Verification(Cog): Sleep until `REMINDER_MESSAGE` should be sent again. If latest reminder is not cached, exit instantly. Otherwise, wait wait until the - configured `REMINDER_FREQUENCY` has passed. + configured `reminder_frequency` has passed. """ last_reminder: t.Optional[int] = await self.task_cache.get("last_reminder") @@ -486,7 +485,7 @@ class Verification(Cog): to_sleep = timedelta(hours=constants.Verification.reminder_frequency) - time_since log.trace(f"Time to sleep until next ping: {to_sleep}") - # Delta can be negative if `REMINDER_FREQUENCY` has already passed + # Delta can be negative if `reminder_frequency` has already passed secs = max(to_sleep.total_seconds(), 0) await asyncio.sleep(secs) -- cgit v1.2.3 From 93145528d7859842602df5c6535f3995187ffadb Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Wed, 16 Sep 2020 17:34:12 +0200 Subject: Updating names of reddit emotes. --- config-default.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/config-default.yml b/config-default.yml index 20254d584..e97ebf0e8 100644 --- a/config-default.yml +++ b/config-default.yml @@ -76,9 +76,10 @@ style: ducky_maul: &DUCKY_MAUL 640137724958867467 ducky_santa: &DUCKY_SANTA 655360331002019870 - upvotes: "<:upvotes:638729835245731840>" - comments: "<:comments:638729835073765387>" - user: "<:user:638729835442602003>" + # emotes used for #reddit + upvotes: "<:reddit_upvotes:638729835245731840>" + comments: "<:reddit_comments:638729835073765387>" + user: "<:reddit_user:638729835442602003>" icons: crown_blurple: "https://cdn.discordapp.com/emojis/469964153289965568.png" -- cgit v1.2.3 From f8272b4cdcab08145c1cecedd8bbbfea4bbf3239 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Wed, 16 Sep 2020 19:42:10 +0200 Subject: update the reddit emojis to actual emojis' --- config-default.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config-default.yml b/config-default.yml index e97ebf0e8..87bfd1378 100644 --- a/config-default.yml +++ b/config-default.yml @@ -77,9 +77,9 @@ style: ducky_santa: &DUCKY_SANTA 655360331002019870 # emotes used for #reddit - upvotes: "<:reddit_upvotes:638729835245731840>" - comments: "<:reddit_comments:638729835073765387>" - user: "<:reddit_user:638729835442602003>" + upvotes: "<:reddit_upvotes:755845219890757644> " + comments: "<:reddit_comments:755845255001014384>" + user: "<:reddit_users:755845303822974997>" icons: crown_blurple: "https://cdn.discordapp.com/emojis/469964153289965568.png" -- cgit v1.2.3 From 6cef33ee7b68d07a1026b784f0350b146469702d Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Wed, 16 Sep 2020 19:43:44 +0200 Subject: remove random space in `upvotes` value --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 87bfd1378..58651f548 100644 --- a/config-default.yml +++ b/config-default.yml @@ -77,7 +77,7 @@ style: ducky_santa: &DUCKY_SANTA 655360331002019870 # emotes used for #reddit - upvotes: "<:reddit_upvotes:755845219890757644> " + upvotes: "<:reddit_upvotes:755845219890757644>" comments: "<:reddit_comments:755845255001014384>" user: "<:reddit_users:755845303822974997>" -- cgit v1.2.3