From be9cb47eddb2c4920a7e0956a79ca555a7243bf7 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 18 May 2020 11:15:05 +0300 Subject: EH Tests: Created test cases set + already handled error test Created test that make sure when error is already handled in local error handler, this don't await `ctx.send` to make sure that this don't move forward. --- tests/bot/cogs/test_error_handler.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 tests/bot/cogs/test_error_handler.py diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py new file mode 100644 index 000000000..6aca03030 --- /dev/null +++ b/tests/bot/cogs/test_error_handler.py @@ -0,0 +1,22 @@ +import unittest + +from discord.ext.commands import errors + +from bot.cogs.error_handler import ErrorHandler +from tests.helpers import MockBot, MockContext + + +class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): + """Tests for error handler functionality.""" + + def setUp(self): + self.bot = MockBot() + self.ctx = MockContext(bot=self.bot) + self.cog = ErrorHandler(self.bot) + + async def test_error_handler_already_handled(self): + """Should not do anything when error is already handled by local error handler.""" + error = errors.CommandError() + error.handled = "foo" + await self.cog.on_command_error(self.ctx, error) + self.ctx.send.assert_not_awaited() -- cgit v1.2.3 From 8229d2191bf3c3dfd4eeca5188e6463d41c6c6ff Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 18 May 2020 12:58:57 +0300 Subject: EH Tests: Created test for `CommandNotFound` error when call isn't by EH --- tests/bot/cogs/test_error_handler.py | 49 ++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index 6aca03030..510c66b29 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -1,4 +1,5 @@ import unittest +from unittest.mock import AsyncMock, patch from discord.ext.commands import errors @@ -20,3 +21,51 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): error.handled = "foo" await self.cog.on_command_error(self.ctx, error) self.ctx.send.assert_not_awaited() + + async def test_error_handler_command_not_found_error_not_invoked_by_handler(self): + """Should try first (un)silence channel, when fail and channel is not verification channel try to get tag.""" + error = errors.CommandNotFound() + test_cases = ( + { + "try_silence_return": True, + "patch_verification_id": False, + "called_try_get_tag": False + }, + { + "try_silence_return": False, + "patch_verification_id": True, + "called_try_get_tag": False + }, + { + "try_silence_return": False, + "patch_verification_id": False, + "called_try_get_tag": True + } + ) + self.cog.try_silence = AsyncMock() + self.cog.try_get_tag = AsyncMock() + + for case in test_cases: + with self.subTest(try_silence_return=case["try_silence_return"], try_get_tag=case["called_try_get_tag"]): + self.ctx.reset_mock() + self.cog.try_silence.reset_mock(return_value=True) + self.cog.try_get_tag.reset_mock() + + self.cog.try_silence.return_value = case["try_silence_return"] + self.ctx.channel.id = 1234 + + if case["patch_verification_id"]: + with patch("bot.cogs.error_handler.Channels.verification", new=1234): + self.assertIsNone(await self.cog.on_command_error(self.ctx, error)) + else: + self.assertIsNone(await self.cog.on_command_error(self.ctx, error)) + if case["try_silence_return"]: + self.cog.try_get_tag.assert_not_awaited() + self.cog.try_silence.assert_awaited_once() + else: + self.cog.try_silence.assert_awaited_once() + if case["patch_verification_id"]: + self.cog.try_get_tag.assert_not_awaited() + else: + self.cog.try_get_tag.assert_awaited_once() + self.ctx.send.assert_not_awaited() -- cgit v1.2.3 From 28e83216c6c976064e3295911df428baad3419e9 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 18 May 2020 13:03:23 +0300 Subject: EH Tests: Remove class member `cog` + other small changes - Added `assertIsNone` to `test_error_handler_already_handled`. - Removed `ErrorHandlerTests.cog`, moved it to each test recreation. --- tests/bot/cogs/test_error_handler.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index 510c66b29..3945bbb90 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -13,13 +13,13 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): def setUp(self): self.bot = MockBot() self.ctx = MockContext(bot=self.bot) - self.cog = ErrorHandler(self.bot) async def test_error_handler_already_handled(self): """Should not do anything when error is already handled by local error handler.""" + cog = ErrorHandler(self.bot) error = errors.CommandError() error.handled = "foo" - await self.cog.on_command_error(self.ctx, error) + self.assertIsNone(await cog.on_command_error(self.ctx, error)) self.ctx.send.assert_not_awaited() async def test_error_handler_command_not_found_error_not_invoked_by_handler(self): @@ -42,30 +42,31 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): "called_try_get_tag": True } ) - self.cog.try_silence = AsyncMock() - self.cog.try_get_tag = AsyncMock() + cog = ErrorHandler(self.bot) + cog.try_silence = AsyncMock() + cog.try_get_tag = AsyncMock() for case in test_cases: with self.subTest(try_silence_return=case["try_silence_return"], try_get_tag=case["called_try_get_tag"]): self.ctx.reset_mock() - self.cog.try_silence.reset_mock(return_value=True) - self.cog.try_get_tag.reset_mock() + cog.try_silence.reset_mock(return_value=True) + cog.try_get_tag.reset_mock() - self.cog.try_silence.return_value = case["try_silence_return"] + cog.try_silence.return_value = case["try_silence_return"] self.ctx.channel.id = 1234 if case["patch_verification_id"]: with patch("bot.cogs.error_handler.Channels.verification", new=1234): - self.assertIsNone(await self.cog.on_command_error(self.ctx, error)) + self.assertIsNone(await cog.on_command_error(self.ctx, error)) else: - self.assertIsNone(await self.cog.on_command_error(self.ctx, error)) + self.assertIsNone(await cog.on_command_error(self.ctx, error)) if case["try_silence_return"]: - self.cog.try_get_tag.assert_not_awaited() - self.cog.try_silence.assert_awaited_once() + cog.try_get_tag.assert_not_awaited() + cog.try_silence.assert_awaited_once() else: - self.cog.try_silence.assert_awaited_once() + cog.try_silence.assert_awaited_once() if case["patch_verification_id"]: - self.cog.try_get_tag.assert_not_awaited() + cog.try_get_tag.assert_not_awaited() else: - self.cog.try_get_tag.assert_awaited_once() + cog.try_get_tag.assert_awaited_once() self.ctx.send.assert_not_awaited() -- cgit v1.2.3 From 3ea33bc9e0c313ba62afddae7494a40690ef6e71 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 18 May 2020 14:28:09 +0300 Subject: Error Handler: Changed `CommandNotFound` error check for testing Replaced `hasattr` with `getattr` for unit tests --- bot/cogs/error_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 23d1eed82..65cf5e37b 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -50,7 +50,7 @@ class ErrorHandler(Cog): log.trace(f"Command {command} had its error already handled locally; ignoring.") return - if isinstance(e, errors.CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): + if isinstance(e, errors.CommandNotFound) and not getattr(ctx, "invoked_from_error_handler", False): if await self.try_silence(ctx): return if ctx.channel.id != Channels.verification: -- cgit v1.2.3 From 28e962bcaa47efca836ad20df5721266336d028e Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 18 May 2020 14:29:07 +0300 Subject: Test Helpers: Added new attribute to `MockContext` Added `invoked_from_error_handler` attribute that is `False` default. --- tests/helpers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/helpers.py b/tests/helpers.py index 2b79a6c2a..0e1581e23 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -368,6 +368,7 @@ message_instance = discord.Message(state=state, channel=channel, data=message_da # Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context` context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock()) +context_instance.invoked_from_error_handler = None class MockContext(CustomMockMixin, unittest.mock.MagicMock): @@ -385,6 +386,7 @@ class MockContext(CustomMockMixin, unittest.mock.MagicMock): self.guild = kwargs.get('guild', MockGuild()) self.author = kwargs.get('author', MockMember()) self.channel = kwargs.get('channel', MockTextChannel()) + self.invoked_from_error_handler = kwargs.get('invoked_from_error_handler', False) attachment_instance = discord.Attachment(data=unittest.mock.MagicMock(id=1), state=unittest.mock.MagicMock()) -- cgit v1.2.3 From 68ba2920c86a6705aebbb7ea1249123cec699827 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 18 May 2020 14:31:18 +0300 Subject: EH Tests: Added another test for `CommandNotFound` error handling Added test for case when `Context.invoked_from_error_handler` is `True` --- tests/bot/cogs/test_error_handler.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index 3945bbb90..16b4e9742 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -16,6 +16,7 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): async def test_error_handler_already_handled(self): """Should not do anything when error is already handled by local error handler.""" + self.ctx.reset_mock() cog = ErrorHandler(self.bot) error = errors.CommandError() error.handled = "foo" @@ -70,3 +71,19 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): else: cog.try_get_tag.assert_awaited_once() self.ctx.send.assert_not_awaited() + + async def test_error_handler_command_not_found_error_invoked_by_handler(self): + """Should do nothing when error is `CommandNotFound` and have attribute `invoked_from_error_handler`.""" + ctx = MockContext(bot=self.bot, invoked_from_error_handler=True) + + cog = ErrorHandler(self.bot) + cog.try_silence = AsyncMock() + cog.try_get_tag = AsyncMock() + + error = errors.CommandNotFound() + + self.assertIsNone(await cog.on_command_error(ctx, error)) + + cog.try_silence.assert_not_awaited() + cog.try_get_tag.assert_not_awaited() + self.ctx.send.assert_not_awaited() -- cgit v1.2.3 From 8eacf9c3070443aa213f50484cf2d7510f768d08 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 18 May 2020 15:02:46 +0300 Subject: EH Tests: Added test for `UserInputError` handling on handler --- tests/bot/cogs/test_error_handler.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index 16b4e9742..a974b421a 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -87,3 +87,12 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): cog.try_silence.assert_not_awaited() cog.try_get_tag.assert_not_awaited() self.ctx.send.assert_not_awaited() + + async def test_error_handler_user_input_error(self): + """Should await `ErrorHandler.handle_user_input_error` when error is `UserInputError`.""" + self.ctx.reset_mock() + cog = ErrorHandler(self.bot) + cog.handle_user_input_error = AsyncMock() + error = errors.UserInputError() + self.assertIsNone(await cog.on_command_error(self.ctx, error)) + cog.handle_user_input_error.assert_awaited_once_with(self.ctx, error) -- cgit v1.2.3 From c050dee853a2fb7432b263cda30becf760310377 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 18 May 2020 15:51:48 +0300 Subject: EH Tests: Added test for `CheckFailure` handling on handler --- tests/bot/cogs/test_error_handler.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index a974b421a..1cae8a517 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -96,3 +96,12 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): error = errors.UserInputError() self.assertIsNone(await cog.on_command_error(self.ctx, error)) cog.handle_user_input_error.assert_awaited_once_with(self.ctx, error) + + async def test_error_handler_check_failure(self): + """Should await `ErrorHandler.handle_check_failure` when error is `CheckFailure`.""" + self.ctx.reset_mock() + cog = ErrorHandler(self.bot) + cog.handle_check_failure = AsyncMock() + error = errors.CheckFailure() + self.assertIsNone(await cog.on_command_error(self.ctx, error)) + cog.handle_check_failure.assert_awaited_once_with(self.ctx, error) -- cgit v1.2.3 From ae9766a2cc01ddcc15b4d64568e2cfc67673f138 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 18 May 2020 15:57:01 +0300 Subject: EH Tests: Added test for `CommandOnCooldown` handling on handler --- tests/bot/cogs/test_error_handler.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index 1cae8a517..2acca7450 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -105,3 +105,11 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): error = errors.CheckFailure() self.assertIsNone(await cog.on_command_error(self.ctx, error)) cog.handle_check_failure.assert_awaited_once_with(self.ctx, error) + + async def test_error_handler_command_on_cooldown(self): + """Should send error with `ctx.send` when error is `CommandOnCooldown`.""" + self.ctx.reset_mock() + cog = ErrorHandler(self.bot) + error = errors.CommandOnCooldown(10, 9) + self.assertIsNone(await cog.on_command_error(self.ctx, error)) + self.ctx.send.assert_awaited_once_with(error) -- cgit v1.2.3 From 6d9698fd3b6fd0b6a02898546c665ef7f302127a Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 18 May 2020 16:08:37 +0300 Subject: EH Tests: Added test for `CommandInvokeError` handling on handler --- tests/bot/cogs/test_error_handler.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index 2acca7450..09543b56d 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, patch from discord.ext.commands import errors +from bot.api import ResponseCodeError from bot.cogs.error_handler import ErrorHandler from tests.helpers import MockBot, MockContext @@ -113,3 +114,24 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): error = errors.CommandOnCooldown(10, 9) self.assertIsNone(await cog.on_command_error(self.ctx, error)) self.ctx.send.assert_awaited_once_with(error) + + async def test_error_handler_command_invoke_error(self): + """Should call `handle_api_error` or `handle_unexpected_error` depending on original error.""" + cog = ErrorHandler(self.bot) + cog.handle_api_error = AsyncMock() + cog.handle_unexpected_error = AsyncMock() + test_cases = ( + { + "args": (self.ctx, errors.CommandInvokeError(ResponseCodeError(AsyncMock()))), + "expect_mock_call": cog.handle_api_error + }, + { + "args": (self.ctx, errors.CommandInvokeError(TypeError)), + "expect_mock_call": cog.handle_unexpected_error + } + ) + + for case in test_cases: + with self.subTest(args=case["args"], expect_mock_call=case["expect_mock_call"]): + self.assertIsNone(await cog.on_command_error(*case["args"])) + case["expect_mock_call"].assert_awaited_once_with(self.ctx, case["args"][1].original) -- cgit v1.2.3 From cf89c5be49bfe57934d2fea9408a44f78c4db3b6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 18 May 2020 16:18:40 +0300 Subject: EH Tests: Added test for 3 errors handling on handler These 3 errors is: - `ConversionError` - `MaxConcurrencyReached` - `ExtensionError` --- tests/bot/cogs/test_error_handler.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index 09543b56d..bc67e9d7c 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -1,5 +1,5 @@ import unittest -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from discord.ext.commands import errors @@ -135,3 +135,19 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): with self.subTest(args=case["args"], expect_mock_call=case["expect_mock_call"]): self.assertIsNone(await cog.on_command_error(*case["args"])) case["expect_mock_call"].assert_awaited_once_with(self.ctx, case["args"][1].original) + + async def test_error_handler_three_other_errors(self): + """Should call `handle_unexpected_error` when `ConversionError`, `MaxConcurrencyReached` or `ExtensionError`.""" + cog = ErrorHandler(self.bot) + cog.handle_unexpected_error = AsyncMock() + errs = ( + errors.ConversionError(MagicMock(), MagicMock()), + errors.MaxConcurrencyReached(1, MagicMock()), + errors.ExtensionError(name="foo") + ) + + for err in errs: + with self.subTest(error=err): + cog.handle_unexpected_error.reset_mock() + self.assertIsNone(await cog.on_command_error(self.ctx, err)) + cog.handle_unexpected_error.assert_awaited_once_with(self.ctx, err) -- cgit v1.2.3 From 800909176c3799b12d8bbb41fba4be336b199933 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 18 May 2020 16:25:59 +0300 Subject: EH Tests: Created test for all other errors --- tests/bot/cogs/test_error_handler.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index bc67e9d7c..d0a5ba21b 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -151,3 +151,11 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): cog.handle_unexpected_error.reset_mock() self.assertIsNone(await cog.on_command_error(self.ctx, err)) cog.handle_unexpected_error.assert_awaited_once_with(self.ctx, err) + + @patch("bot.cogs.error_handler.log") + async def test_error_handler_other_errors(self, log_mock): + """Should `log.debug` other errors.""" + cog = ErrorHandler(self.bot) + error = errors.DisabledCommand() # Use this just as a other error + self.assertIsNone(await cog.on_command_error(self.ctx, error)) + log_mock.debug.assert_called_once() -- cgit v1.2.3 From faf9737548c82cff5369ec145530f6fc86f8c0cc Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 18 May 2020 19:17:25 +0300 Subject: EH Tests: Created first test for `try_silence` --- tests/bot/cogs/test_error_handler.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index d0a5ba21b..f409cf6bc 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -5,6 +5,7 @@ from discord.ext.commands import errors from bot.api import ResponseCodeError from bot.cogs.error_handler import ErrorHandler +from bot.cogs.moderation.silence import Silence from tests.helpers import MockBot, MockContext @@ -159,3 +160,20 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): error = errors.DisabledCommand() # Use this just as a other error self.assertIsNone(await cog.on_command_error(self.ctx, error)) log_mock.debug.assert_called_once() + + +class TrySilenceTests(unittest.IsolatedAsyncioTestCase): + """Test for helper functions that handle `CommandNotFound` error.""" + + def setUp(self): + self.bot = MockBot() + + async def test_try_silence_context_invoked_from_error_handler(self): + """Should set `Context.invoked_from_error_handler` to `True`.""" + cog = ErrorHandler(self.bot) + ctx = MockContext(bot=self.bot) + ctx.invoked_with = "foo" + self.bot.get_command.return_value = Silence(self.bot).silence + await cog.try_silence(ctx) + self.assertTrue(hasattr(ctx, "invoked_from_error_handler")) + self.assertTrue(ctx.invoked_from_error_handler) -- cgit v1.2.3 From c1ac5c00d211eda081aec948fe74a6a083854e49 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 18 May 2020 19:20:55 +0300 Subject: EH Tests: Created `try_silence` test for `get_command` --- tests/bot/cogs/test_error_handler.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index f409cf6bc..90f4c64a6 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -177,3 +177,12 @@ class TrySilenceTests(unittest.IsolatedAsyncioTestCase): await cog.try_silence(ctx) self.assertTrue(hasattr(ctx, "invoked_from_error_handler")) self.assertTrue(ctx.invoked_from_error_handler) + + async def test_try_silence_get_command(self): + """Should call `get_command` with `silence`.""" + cog = ErrorHandler(self.bot) + ctx = MockContext(bot=self.bot) + ctx.invoked_with = "foo" + self.bot.get_command.return_value = Silence(self.bot).silence + await cog.try_silence(ctx) + self.bot.get_command.assert_called_once_with("silence") -- cgit v1.2.3 From 187998fe0436f60b831a4bf5af44c37e37d1ea34 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 18 May 2020 19:24:19 +0300 Subject: EH Tests: Created `try_silence` test to check no permission response --- tests/bot/cogs/test_error_handler.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index 90f4c64a6..941b57ecb 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -167,13 +167,13 @@ class TrySilenceTests(unittest.IsolatedAsyncioTestCase): def setUp(self): self.bot = MockBot() + self.bot.get_command.return_value = Silence(self.bot).silence async def test_try_silence_context_invoked_from_error_handler(self): """Should set `Context.invoked_from_error_handler` to `True`.""" cog = ErrorHandler(self.bot) ctx = MockContext(bot=self.bot) ctx.invoked_with = "foo" - self.bot.get_command.return_value = Silence(self.bot).silence await cog.try_silence(ctx) self.assertTrue(hasattr(ctx, "invoked_from_error_handler")) self.assertTrue(ctx.invoked_from_error_handler) @@ -183,6 +183,13 @@ class TrySilenceTests(unittest.IsolatedAsyncioTestCase): cog = ErrorHandler(self.bot) ctx = MockContext(bot=self.bot) ctx.invoked_with = "foo" - self.bot.get_command.return_value = Silence(self.bot).silence await cog.try_silence(ctx) self.bot.get_command.assert_called_once_with("silence") + + async def test_try_silence_no_permissions_to_run(self): + """Should return `False` because missing permissions.""" + cog = ErrorHandler(self.bot) + ctx = MockContext(bot=self.bot) + ctx.invoked_with = "foo" + self.bot.get_command.return_value.can_run = AsyncMock(return_value=False) + self.assertFalse(await cog.try_silence(ctx)) -- cgit v1.2.3 From ec3b4eefbf3f1e67a0ade32522667fcc397539c5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 18 May 2020 19:46:15 +0300 Subject: EH Tests: Created `try_silence` test to check no permission with except --- tests/bot/cogs/test_error_handler.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index 941b57ecb..8c51467e5 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -193,3 +193,11 @@ class TrySilenceTests(unittest.IsolatedAsyncioTestCase): ctx.invoked_with = "foo" self.bot.get_command.return_value.can_run = AsyncMock(return_value=False) self.assertFalse(await cog.try_silence(ctx)) + + async def test_try_silence_no_permissions_to_run_command_error(self): + """Should return `False` because `CommandError` raised (no permissions).""" + cog = ErrorHandler(self.bot) + ctx = MockContext(bot=self.bot) + ctx.invoked_with = "foo" + self.bot.get_command.return_value.can_run = AsyncMock(side_effect=errors.CommandError()) + self.assertFalse(await cog.try_silence(ctx)) -- cgit v1.2.3 From f324b0166516687100ad1362807d59be9918b739 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 18 May 2020 19:54:47 +0300 Subject: EH Tests: Created `try_silence` test to test silence command calling --- tests/bot/cogs/test_error_handler.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index 8c51467e5..30326d445 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -201,3 +201,20 @@ class TrySilenceTests(unittest.IsolatedAsyncioTestCase): ctx.invoked_with = "foo" self.bot.get_command.return_value.can_run = AsyncMock(side_effect=errors.CommandError()) self.assertFalse(await cog.try_silence(ctx)) + + async def test_try_silence_silencing(self): + """Should run silence command with correct arguments.""" + cog = ErrorHandler(self.bot) + ctx = MockContext(bot=self.bot) + self.bot.get_command.return_value.can_run = AsyncMock(return_value=True) + test_cases = ("shh", "shhh", "shhhhhh", "shhhhhhhhhhhhhhhhhhh") + + for case in test_cases: + with self.subTest(message=case): + ctx.reset_mock() + ctx.invoked_with = case + self.assertTrue(await cog.try_silence(ctx)) + ctx.invoke.assert_awaited_once_with( + self.bot.get_command.return_value, + duration=min(case.count("h")*2, 15) + ) -- cgit v1.2.3 From 2c488838dec67daa40e11249c9e578c1450ce594 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 18 May 2020 20:05:19 +0300 Subject: EH Tests: Created `try_silence` test to test unsilence command calling --- tests/bot/cogs/test_error_handler.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index 30326d445..610d3ace5 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -218,3 +218,20 @@ class TrySilenceTests(unittest.IsolatedAsyncioTestCase): self.bot.get_command.return_value, duration=min(case.count("h")*2, 15) ) + + async def test_try_silence_unsilence(self): + """Should call unsilence command.""" + bot = MockBot() + silence = Silence(bot) + silence.silence.can_run = AsyncMock(return_value=True) + cog = ErrorHandler(bot) + ctx = MockContext(bot=bot) + test_cases = ("unshh", "unshhhhh", "unshhhhhhhhh") + + for case in test_cases: + with self.subTest(message=case): + bot.get_command.side_effect = (silence.silence, silence.unsilence) + ctx.reset_mock() + ctx.invoked_with = case + self.assertTrue(await cog.try_silence(ctx)) + ctx.invoke.assert_awaited_once_with(silence.unsilence) -- cgit v1.2.3 From 555857c07829b537e048ae030e6b864576d4ef8a Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 18 May 2020 20:07:16 +0300 Subject: EH Tests: Added test for `try_silence` no match message --- tests/bot/cogs/test_error_handler.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index 610d3ace5..8be562473 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -235,3 +235,9 @@ class TrySilenceTests(unittest.IsolatedAsyncioTestCase): ctx.invoked_with = case self.assertTrue(await cog.try_silence(ctx)) ctx.invoke.assert_awaited_once_with(silence.unsilence) + + async def test_try_silence_no_match(self): + cog = ErrorHandler(self.bot) + ctx = MockContext(bot=self.bot) + ctx.invoked_with = "foo" + self.assertFalse(await cog.try_silence(ctx)) -- cgit v1.2.3 From 048916fbf6ecf272c89e325055ff5d755276d75b Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 18 May 2020 20:12:57 +0300 Subject: EH Tests: Cleanup `try_silence` tests --- tests/bot/cogs/test_error_handler.py | 66 +++++++++++++++--------------------- 1 file changed, 27 insertions(+), 39 deletions(-) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index 8be562473..bfb1cfe61 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -167,77 +167,65 @@ class TrySilenceTests(unittest.IsolatedAsyncioTestCase): def setUp(self): self.bot = MockBot() - self.bot.get_command.return_value = Silence(self.bot).silence + self.silence = Silence(self.bot) + self.bot.get_command.return_value = self.silence.silence + self.ctx = MockContext(bot=self.bot) + self.cog = ErrorHandler(self.bot) async def test_try_silence_context_invoked_from_error_handler(self): """Should set `Context.invoked_from_error_handler` to `True`.""" - cog = ErrorHandler(self.bot) - ctx = MockContext(bot=self.bot) - ctx.invoked_with = "foo" - await cog.try_silence(ctx) - self.assertTrue(hasattr(ctx, "invoked_from_error_handler")) - self.assertTrue(ctx.invoked_from_error_handler) + self.ctx.invoked_with = "foo" + await self.cog.try_silence(self.ctx) + self.assertTrue(hasattr(self.ctx, "invoked_from_error_handler")) + self.assertTrue(self.ctx.invoked_from_error_handler) async def test_try_silence_get_command(self): """Should call `get_command` with `silence`.""" - cog = ErrorHandler(self.bot) - ctx = MockContext(bot=self.bot) - ctx.invoked_with = "foo" - await cog.try_silence(ctx) + self.ctx.invoked_with = "foo" + await self.cog.try_silence(self.ctx) self.bot.get_command.assert_called_once_with("silence") async def test_try_silence_no_permissions_to_run(self): """Should return `False` because missing permissions.""" - cog = ErrorHandler(self.bot) - ctx = MockContext(bot=self.bot) - ctx.invoked_with = "foo" + self.ctx.invoked_with = "foo" self.bot.get_command.return_value.can_run = AsyncMock(return_value=False) - self.assertFalse(await cog.try_silence(ctx)) + self.assertFalse(await self.cog.try_silence(self.ctx)) async def test_try_silence_no_permissions_to_run_command_error(self): """Should return `False` because `CommandError` raised (no permissions).""" - cog = ErrorHandler(self.bot) - ctx = MockContext(bot=self.bot) - ctx.invoked_with = "foo" + self.ctx.invoked_with = "foo" self.bot.get_command.return_value.can_run = AsyncMock(side_effect=errors.CommandError()) - self.assertFalse(await cog.try_silence(ctx)) + self.assertFalse(await self.cog.try_silence(self.ctx)) async def test_try_silence_silencing(self): """Should run silence command with correct arguments.""" - cog = ErrorHandler(self.bot) - ctx = MockContext(bot=self.bot) self.bot.get_command.return_value.can_run = AsyncMock(return_value=True) test_cases = ("shh", "shhh", "shhhhhh", "shhhhhhhhhhhhhhhhhhh") for case in test_cases: with self.subTest(message=case): - ctx.reset_mock() - ctx.invoked_with = case - self.assertTrue(await cog.try_silence(ctx)) - ctx.invoke.assert_awaited_once_with( + self.ctx.reset_mock() + self.ctx.invoked_with = case + self.assertTrue(await self.cog.try_silence(self.ctx)) + self.ctx.invoke.assert_awaited_once_with( self.bot.get_command.return_value, duration=min(case.count("h")*2, 15) ) async def test_try_silence_unsilence(self): """Should call unsilence command.""" - bot = MockBot() - silence = Silence(bot) - silence.silence.can_run = AsyncMock(return_value=True) - cog = ErrorHandler(bot) - ctx = MockContext(bot=bot) + self.silence.silence.can_run = AsyncMock(return_value=True) test_cases = ("unshh", "unshhhhh", "unshhhhhhhhh") for case in test_cases: with self.subTest(message=case): - bot.get_command.side_effect = (silence.silence, silence.unsilence) - ctx.reset_mock() - ctx.invoked_with = case - self.assertTrue(await cog.try_silence(ctx)) - ctx.invoke.assert_awaited_once_with(silence.unsilence) + self.bot.get_command.side_effect = (self.silence.silence, self.silence.unsilence) + self.ctx.reset_mock() + self.ctx.invoked_with = case + self.assertTrue(await self.cog.try_silence(self.ctx)) + self.ctx.invoke.assert_awaited_once_with(self.silence.unsilence) async def test_try_silence_no_match(self): - cog = ErrorHandler(self.bot) - ctx = MockContext(bot=self.bot) - ctx.invoked_with = "foo" - self.assertFalse(await cog.try_silence(ctx)) + """Should return `False` when message don't match.""" + self.ctx.invoked_with = "foo" + self.assertFalse(await self.cog.try_silence(self.ctx)) -- cgit v1.2.3 From 33231d5881063c720ab54b295d36030b4b304002 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 19 May 2020 08:18:55 +0300 Subject: EH Tests: Added tests for `get_help_command`in `OtherErrorHandlerTests` --- tests/bot/cogs/test_error_handler.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index bfb1cfe61..615c0e455 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -229,3 +229,35 @@ class TrySilenceTests(unittest.IsolatedAsyncioTestCase): """Should return `False` when message don't match.""" self.ctx.invoked_with = "foo" self.assertFalse(await self.cog.try_silence(self.ctx)) + + +class OtherErrorHandlerTests(unittest.IsolatedAsyncioTestCase): + """Other `ErrorHandler` tests.""" + + def setUp(self): + self.bot = MockBot() + self.ctx = MockContext() + + async def test_get_help_command_command_specified(self): + """Should return coroutine of help command of specified command.""" + self.ctx.command = "foo" + result = ErrorHandler.get_help_command(self.ctx) + expected = self.ctx.send_help("foo") + self.assertEqual(result.__qualname__, expected.__qualname__) + self.assertEqual(result.cr_frame.f_locals, expected.cr_frame.f_locals) + + # Await coroutines to avoid warnings + await result + await expected + + async def test_get_help_command_no_command_specified(self): + """Should return coroutine of help command.""" + self.ctx.command = None + result = ErrorHandler.get_help_command(self.ctx) + expected = self.ctx.send_help() + self.assertEqual(result.__qualname__, expected.__qualname__) + self.assertEqual(result.cr_frame.f_locals, expected.cr_frame.f_locals) + + # Await coroutines to avoid warnings + await result + await expected -- cgit v1.2.3 From eb9909f90706803694a37ae30336c3d6a4b475ab Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 19 May 2020 08:27:49 +0300 Subject: EH Tests: Added test for `try_get_tag` `get_command` calling --- tests/bot/cogs/test_error_handler.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index 615c0e455..b92a67fbe 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -6,6 +6,7 @@ from discord.ext.commands import errors from bot.api import ResponseCodeError from bot.cogs.error_handler import ErrorHandler from bot.cogs.moderation.silence import Silence +from bot.cogs.tags import Tags from tests.helpers import MockBot, MockContext @@ -231,6 +232,24 @@ class TrySilenceTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(await self.cog.try_silence(self.ctx)) +class TryGetTagTests(unittest.IsolatedAsyncioTestCase): + """Tests for `try_get_tag` function.""" + + def setUp(self): + self.bot = MockBot() + self.ctx = MockContext() + self.tag = Tags(self.bot) + self.cog = ErrorHandler(self.bot) + self.bot.get_command.return_value = self.tag.get_command + + async def test_try_get_tag_get_command(self): + """Should call `Bot.get_command` with `tags get` argument.""" + self.bot.get_command.reset_mock() + self.ctx.invoked_with = "my_some_not_existing_tag" + await self.cog.try_get_tag(self.ctx) + self.bot.get_command.assert_called_once_with("tags get") + + class OtherErrorHandlerTests(unittest.IsolatedAsyncioTestCase): """Other `ErrorHandler` tests.""" -- cgit v1.2.3 From 1a796c472273a0e1d98ae52a13550791592f856c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 19 May 2020 08:30:25 +0300 Subject: EH Tests: Added test for `try_get_tag` `invoked_from_error_handler` --- tests/bot/cogs/test_error_handler.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index b92a67fbe..35c223fcc 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -249,6 +249,13 @@ class TryGetTagTests(unittest.IsolatedAsyncioTestCase): await self.cog.try_get_tag(self.ctx) self.bot.get_command.assert_called_once_with("tags get") + async def test_try_get_tag_invoked_from_error_handler(self): + """`self.ctx` should have `invoked_from_error_handler` `True`.""" + self.ctx.invoked_from_error_handler = False + self.ctx.invoked_with = "my_some_not_existing_tag" + await self.cog.try_get_tag(self.ctx) + self.assertTrue(self.ctx.invoked_from_error_handler) + class OtherErrorHandlerTests(unittest.IsolatedAsyncioTestCase): """Other `ErrorHandler` tests.""" -- cgit v1.2.3 From 0ffded4eaa2427a04f43aa3bb6088c3ac1e91f0e Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 19 May 2020 08:35:15 +0300 Subject: EH Tests: Added test for `try_get_tag` checks fail --- tests/bot/cogs/test_error_handler.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index 35c223fcc..ba05076dd 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -245,17 +245,23 @@ class TryGetTagTests(unittest.IsolatedAsyncioTestCase): async def test_try_get_tag_get_command(self): """Should call `Bot.get_command` with `tags get` argument.""" self.bot.get_command.reset_mock() - self.ctx.invoked_with = "my_some_not_existing_tag" + self.ctx.invoked_with = "foo" await self.cog.try_get_tag(self.ctx) self.bot.get_command.assert_called_once_with("tags get") async def test_try_get_tag_invoked_from_error_handler(self): """`self.ctx` should have `invoked_from_error_handler` `True`.""" self.ctx.invoked_from_error_handler = False - self.ctx.invoked_with = "my_some_not_existing_tag" + self.ctx.invoked_with = "foo" await self.cog.try_get_tag(self.ctx) self.assertTrue(self.ctx.invoked_from_error_handler) + async def test_try_get_tag_no_permissions(self): + """Should return `False` because checks fail.""" + self.tag.get_command.can_run = AsyncMock(return_value=False) + self.ctx.invoked_with = "foo" + self.assertFalse(await self.cog.try_get_tag(self.ctx)) + class OtherErrorHandlerTests(unittest.IsolatedAsyncioTestCase): """Other `ErrorHandler` tests.""" -- cgit v1.2.3 From c2d111f32a04a4f0c5a7b02d4418b2c628b0115a Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 19 May 2020 08:41:07 +0300 Subject: EH Tests: Added test for `try_get_tag` error handling --- tests/bot/cogs/test_error_handler.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index ba05076dd..a22724aff 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -257,10 +257,19 @@ class TryGetTagTests(unittest.IsolatedAsyncioTestCase): self.assertTrue(self.ctx.invoked_from_error_handler) async def test_try_get_tag_no_permissions(self): - """Should return `False` because checks fail.""" + """Test how to handle checks failing.""" self.tag.get_command.can_run = AsyncMock(return_value=False) self.ctx.invoked_with = "foo" - self.assertFalse(await self.cog.try_get_tag(self.ctx)) + self.assertIsNone(await self.cog.try_get_tag(self.ctx)) + + async def test_try_get_tag_command_error(self): + """Should call `on_command_error` when `CommandError` raised.""" + err = errors.CommandError() + self.tag.get_command.can_run = AsyncMock(side_effect=err) + self.cog.on_command_error = AsyncMock() + self.ctx.invoked_with = "foo" + self.assertIsNone(await self.cog.try_get_tag(self.ctx)) + self.cog.on_command_error.assert_awaited_once_with(self.ctx, err) class OtherErrorHandlerTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From 2c1cbc21e5f7046f765d9ed5145a4f06464a8f6f Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 19 May 2020 08:49:40 +0300 Subject: EH Tests: Added test for `try_get_tag` successful tag name converting --- tests/bot/cogs/test_error_handler.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index a22724aff..c95453c8c 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -271,6 +271,15 @@ class TryGetTagTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.try_get_tag(self.ctx)) self.cog.on_command_error.assert_awaited_once_with(self.ctx, err) + @patch("bot.cogs.error_handler.TagNameConverter") + async def test_try_get_tag_convert_success(self, tag_converter): + """Converting tag should successful.""" + self.ctx.invoked_with = "foo" + tag_converter.convert = AsyncMock(return_value="foo") + self.assertIsNone(await self.cog.try_get_tag(self.ctx)) + tag_converter.convert.assert_awaited_once_with(self.ctx, "foo") + self.ctx.invoke.assert_awaited_once() + class OtherErrorHandlerTests(unittest.IsolatedAsyncioTestCase): """Other `ErrorHandler` tests.""" -- cgit v1.2.3 From 558b5d86c37f81dd71b19aed9ef9dc4a1d899dd0 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 19 May 2020 08:52:30 +0300 Subject: EH Tests: Added test for `try_get_tag` tag name converting failing --- tests/bot/cogs/test_error_handler.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index c95453c8c..dfebf3379 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -280,6 +280,15 @@ class TryGetTagTests(unittest.IsolatedAsyncioTestCase): tag_converter.convert.assert_awaited_once_with(self.ctx, "foo") self.ctx.invoke.assert_awaited_once() + @patch("bot.cogs.error_handler.TagNameConverter") + async def test_try_get_tag_convert_fail(self, tag_converter): + """Converting tag should raise `BadArgument`.""" + self.ctx.reset_mock() + self.ctx.invoked_with = "bar" + tag_converter.convert = AsyncMock(side_effect=errors.BadArgument()) + self.assertIsNone(await self.cog.try_get_tag(self.ctx)) + self.ctx.invoke.assert_not_awaited() + class OtherErrorHandlerTests(unittest.IsolatedAsyncioTestCase): """Other `ErrorHandler` tests.""" -- cgit v1.2.3 From 9e4e9ef3f00f03e8e4b5260e458a1fc5c9318a57 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 19 May 2020 08:58:41 +0300 Subject: EH Tests: Added test for `try_get_tag` `ctx.invoke` calling --- tests/bot/cogs/test_error_handler.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index dfebf3379..43092f082 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -289,6 +289,13 @@ class TryGetTagTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.try_get_tag(self.ctx)) self.ctx.invoke.assert_not_awaited() + async def test_try_get_tag_ctx_invoke(self): + """Should call `ctx.invoke` with proper args/kwargs.""" + self.ctx.reset_mock() + self.ctx.invoked_with = "foo" + self.assertIsNone(await self.cog.try_get_tag(self.ctx)) + self.ctx.invoke.assert_awaited_once_with(self.tag.get_command, tag_name="foo") + class OtherErrorHandlerTests(unittest.IsolatedAsyncioTestCase): """Other `ErrorHandler` tests.""" -- cgit v1.2.3 From 1715b2e0461b142009757b04f257cf999779a668 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 19 May 2020 09:01:48 +0300 Subject: EH Tests: Added test for `try_get_tag` `ResponseCodeError` ignore --- tests/bot/cogs/test_error_handler.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index 43092f082..6dda85304 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -296,6 +296,12 @@ class TryGetTagTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.try_get_tag(self.ctx)) self.ctx.invoke.assert_awaited_once_with(self.tag.get_command, tag_name="foo") + async def test_try_get_tag_response_code_error_suppress(self): + """Should suppress `ResponseCodeError` when calling `ctx.invoke`.""" + self.ctx.invoked_with = "foo" + self.ctx.invoke.side_effect = ResponseCodeError(MagicMock()) + self.assertIsNone(await self.cog.try_get_tag(self.ctx)) + class OtherErrorHandlerTests(unittest.IsolatedAsyncioTestCase): """Other `ErrorHandler` tests.""" -- cgit v1.2.3 From 98c30a30f24feb227bf7e921e825b7124515d640 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 19 May 2020 09:14:25 +0300 Subject: EH Tests: Created test for `handle_user_input_error` `get_help_command` --- tests/bot/cogs/test_error_handler.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index 6dda85304..377fc5228 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -303,6 +303,22 @@ class TryGetTagTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.try_get_tag(self.ctx)) +class UserInputErrorHandlerTests(unittest.IsolatedAsyncioTestCase): + """Tests for `handle_user_input_error`.""" + + def setUp(self): + self.bot = MockBot() + self.ctx = MockContext(bot=self.bot) + self.cog = ErrorHandler(self.bot) + + @patch("bot.cogs.error_handler.ErrorHandler.get_help_command") + async def test_handle_input_error_handler_get_help_command_call(self, get_help_command): + """Should call `ErrorHandler.get_help_command`.""" + get_help_command.return_value = self.ctx.send_help(self.ctx) + await self.cog.handle_user_input_error(self.ctx, errors.UserInputError()) + get_help_command.assert_called_once_with(self.ctx) + + class OtherErrorHandlerTests(unittest.IsolatedAsyncioTestCase): """Other `ErrorHandler` tests.""" -- cgit v1.2.3 From e950deff01e633eb899da2a915cb5bff8e43e4c5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 19 May 2020 09:50:32 +0300 Subject: Error Handler: Changed way of help command get + send to avoid warning Only get coroutine when this is gonna be awaited. --- bot/cogs/error_handler.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 65cf5e37b..a70ebe109 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -159,19 +159,17 @@ class ErrorHandler(Cog): * ArgumentParsingError: send an error message * Other: send an error message and the help command """ - prepared_help_command = self.get_help_command(ctx) - if isinstance(e, errors.MissingRequiredArgument): await ctx.send(f"Missing required argument `{e.param.name}`.") - await prepared_help_command + await self.get_help_command(ctx) self.bot.stats.incr("errors.missing_required_argument") elif isinstance(e, errors.TooManyArguments): await ctx.send(f"Too many arguments provided.") - await prepared_help_command + await self.get_help_command(ctx) self.bot.stats.incr("errors.too_many_arguments") elif isinstance(e, errors.BadArgument): await ctx.send(f"Bad argument: {e}\n") - await prepared_help_command + await self.get_help_command(ctx) self.bot.stats.incr("errors.bad_argument") elif isinstance(e, errors.BadUnionArgument): await ctx.send(f"Bad argument: {e}\n```{e.errors[-1]}```") @@ -181,7 +179,7 @@ class ErrorHandler(Cog): self.bot.stats.incr("errors.argument_parsing_error") else: await ctx.send("Something about your input seems off. Check the arguments:") - await prepared_help_command + await self.get_help_command(ctx) self.bot.stats.incr("errors.other_user_input_error") @staticmethod -- cgit v1.2.3 From 18938a9238ac1bd7412aa636190d23de155cc15d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 19 May 2020 09:51:55 +0300 Subject: EH Tests: Create test for `handle_input_error` --- tests/bot/cogs/test_error_handler.py | 42 ++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index 377fc5228..e3d22e82b 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -311,12 +311,42 @@ class UserInputErrorHandlerTests(unittest.IsolatedAsyncioTestCase): self.ctx = MockContext(bot=self.bot) self.cog = ErrorHandler(self.bot) - @patch("bot.cogs.error_handler.ErrorHandler.get_help_command") - async def test_handle_input_error_handler_get_help_command_call(self, get_help_command): - """Should call `ErrorHandler.get_help_command`.""" - get_help_command.return_value = self.ctx.send_help(self.ctx) - await self.cog.handle_user_input_error(self.ctx, errors.UserInputError()) - get_help_command.assert_called_once_with(self.ctx) + async def test_handle_input_error_handler_errors(self): + """Should handle each error probably.""" + test_cases = ( + { + "error": errors.MissingRequiredArgument(MagicMock()), + "call_prepared": True + }, + { + "error": errors.TooManyArguments(), + "call_prepared": True + }, + { + "error": errors.BadArgument(), + "call_prepared": True + }, + { + "error": errors.BadUnionArgument(MagicMock(), MagicMock(), MagicMock()), + "call_prepared": False + }, + { + "error": errors.ArgumentParsingError(), + "call_prepared": False + }, + { + "error": errors.UserInputError(), + "call_prepared": True + } + ) + + for case in test_cases: + with self.subTest(error=case["error"], call_prepared=case["call_prepared"]): + self.ctx.reset_mock() + self.assertIsNone(await self.cog.handle_user_input_error(self.ctx, case["error"])) + self.ctx.send.assert_awaited_once() + if case["call_prepared"]: + self.ctx.send_help.assert_awaited_once() class OtherErrorHandlerTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From 42083621a48972c784a910961a2357a6b9be4aca Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 19 May 2020 10:02:57 +0300 Subject: EH Tests: Create test for `handle_check_failure` --- tests/bot/cogs/test_error_handler.py | 48 ++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index e3d22e82b..b12d21f75 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -7,6 +7,7 @@ from bot.api import ResponseCodeError from bot.cogs.error_handler import ErrorHandler from bot.cogs.moderation.silence import Silence from bot.cogs.tags import Tags +from bot.decorators import InWhitelistCheckFailure from tests.helpers import MockBot, MockContext @@ -349,6 +350,53 @@ class UserInputErrorHandlerTests(unittest.IsolatedAsyncioTestCase): self.ctx.send_help.assert_awaited_once() +class CheckFailureHandlingTests(unittest.IsolatedAsyncioTestCase): + """Tests for `handle_check_failure`.""" + + def setUp(self): + self.bot = MockBot() + self.ctx = MockContext(bot=self.bot) + self.cog = ErrorHandler(self.bot) + + async def test_handle_check_failure_errors(self): + """Should await `ctx.send` when error is check failure.""" + test_cases = ( + { + "error": errors.BotMissingPermissions(MagicMock()), + "call_ctx_send": True + }, + { + "error": errors.BotMissingRole(MagicMock()), + "call_ctx_send": True + }, + { + "error": errors.BotMissingAnyRole(MagicMock()), + "call_ctx_send": True + }, + { + "error": errors.NoPrivateMessage(), + "call_ctx_send": True + }, + { + "error": InWhitelistCheckFailure(1234), + "call_ctx_send": True + }, + { + "error": ResponseCodeError(MagicMock()), + "call_ctx_send": False + } + ) + + for case in test_cases: + with self.subTest(error=case["error"], call_ctx_send=case["call_ctx_send"]): + self.ctx.reset_mock() + await self.cog.handle_check_failure(self.ctx, case["error"]) + if case["call_ctx_send"]: + self.ctx.send.assert_awaited_once() + else: + self.ctx.send.assert_not_awaited() + + class OtherErrorHandlerTests(unittest.IsolatedAsyncioTestCase): """Other `ErrorHandler` tests.""" -- cgit v1.2.3 From decd712419d2daecd15d29efdeb6d74a64ec7946 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 19 May 2020 10:06:01 +0300 Subject: EH Tests: Merge test classes --- tests/bot/cogs/test_error_handler.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index b12d21f75..32c7c4f46 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -304,8 +304,8 @@ class TryGetTagTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.try_get_tag(self.ctx)) -class UserInputErrorHandlerTests(unittest.IsolatedAsyncioTestCase): - """Tests for `handle_user_input_error`.""" +class IndividualErrorHandlerTests(unittest.IsolatedAsyncioTestCase): + """Individual error categories handler tests.""" def setUp(self): self.bot = MockBot() @@ -348,15 +348,8 @@ class UserInputErrorHandlerTests(unittest.IsolatedAsyncioTestCase): self.ctx.send.assert_awaited_once() if case["call_prepared"]: self.ctx.send_help.assert_awaited_once() - - -class CheckFailureHandlingTests(unittest.IsolatedAsyncioTestCase): - """Tests for `handle_check_failure`.""" - - def setUp(self): - self.bot = MockBot() - self.ctx = MockContext(bot=self.bot) - self.cog = ErrorHandler(self.bot) + else: + self.ctx.send_help.assert_not_awaited() async def test_handle_check_failure_errors(self): """Should await `ctx.send` when error is check failure.""" -- cgit v1.2.3 From 6bd696132a76068dc14f97e8fffbce2781823de2 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 19 May 2020 12:44:26 +0300 Subject: EH Tests: Create tests for `handle_api_error` --- tests/bot/cogs/test_error_handler.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index 32c7c4f46..5766727e8 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -389,6 +389,39 @@ class IndividualErrorHandlerTests(unittest.IsolatedAsyncioTestCase): else: self.ctx.send.assert_not_awaited() + @patch("bot.cogs.error_handler.log") + async def test_handle_api_error(self, log_mock): + """Should `ctx.send` on HTTP error codes, `log.debug|warning` depends on code.""" + test_cases = ( + { + "error": ResponseCodeError(AsyncMock(status=400)), + "log_level": "debug" + }, + { + "error": ResponseCodeError(AsyncMock(status=404)), + "log_level": "debug" + }, + { + "error": ResponseCodeError(AsyncMock(status=550)), + "log_level": "warning" + }, + { + "error": ResponseCodeError(AsyncMock(status=1000)), + "log_level": "warning" + } + ) + + for case in test_cases: + with self.subTest(error=case["error"], log_level=case["log_level"]): + self.ctx.reset_mock() + log_mock.reset_mock() + await self.cog.handle_api_error(self.ctx, case["error"]) + self.ctx.send.assert_awaited_once() + if case["log_level"] == "warning": + log_mock.warning.assert_called_once() + else: + log_mock.debug.assert_called_once() + class OtherErrorHandlerTests(unittest.IsolatedAsyncioTestCase): """Other `ErrorHandler` tests.""" -- cgit v1.2.3 From 658f73170100e826fe47ea9f23178a5d071f3932 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 19 May 2020 12:50:43 +0300 Subject: EH Tests: Create test for `ErrorHandler` `setup` function --- tests/bot/cogs/test_error_handler.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index 5766727e8..fbe0d58d5 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from discord.ext.commands import errors from bot.api import ResponseCodeError -from bot.cogs.error_handler import ErrorHandler +from bot.cogs.error_handler import ErrorHandler, setup from bot.cogs.moderation.silence import Silence from bot.cogs.tags import Tags from bot.decorators import InWhitelistCheckFailure @@ -453,3 +453,13 @@ class OtherErrorHandlerTests(unittest.IsolatedAsyncioTestCase): # Await coroutines to avoid warnings await result await expected + + +class ErrorHandlerSetupTests(unittest.TestCase): + """Tests for `ErrorHandler` `setup` function.""" + + def test_setup(self): + """Should call `bot.add_cog` with `ErrorHandler`.""" + bot = MockBot() + setup(bot) + bot.add_cog.assert_called_once() -- cgit v1.2.3 From 96ddcdd3bd12cc9ceb47aa10d17e7354be9e1218 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 19 May 2020 13:08:52 +0300 Subject: EH Tests: Create test for `handle_unexpected_error` --- tests/bot/cogs/test_error_handler.py | 39 ++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/test_error_handler.py b/tests/bot/cogs/test_error_handler.py index fbe0d58d5..08b1e0230 100644 --- a/tests/bot/cogs/test_error_handler.py +++ b/tests/bot/cogs/test_error_handler.py @@ -1,5 +1,5 @@ import unittest -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, call, patch from discord.ext.commands import errors @@ -8,7 +8,7 @@ from bot.cogs.error_handler import ErrorHandler, setup from bot.cogs.moderation.silence import Silence from bot.cogs.tags import Tags from bot.decorators import InWhitelistCheckFailure -from tests.helpers import MockBot, MockContext +from tests.helpers import MockBot, MockContext, MockGuild class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): @@ -422,6 +422,41 @@ class IndividualErrorHandlerTests(unittest.IsolatedAsyncioTestCase): else: log_mock.debug.assert_called_once() + @patch("bot.cogs.error_handler.push_scope") + @patch("bot.cogs.error_handler.log") + async def test_handle_unexpected_error(self, log_mock, push_scope_mock): + """Should `ctx.send` this error, error log this and sent to Sentry.""" + for case in (None, MockGuild()): + with self.subTest(guild=case): + self.ctx.reset_mock() + log_mock.reset_mock() + push_scope_mock.reset_mock() + + self.ctx.guild = case + await self.cog.handle_unexpected_error(self.ctx, errors.CommandError()) + + self.ctx.send.assert_awaited_once() + log_mock.error.assert_called_once() + push_scope_mock.assert_called_once() + + set_tag_calls = [ + call("command", self.ctx.command.qualified_name), + call("message_id", self.ctx.message.id), + call("channel_id", self.ctx.channel.id), + ] + set_extra_calls = [ + call("full_message", self.ctx.message.content) + ] + if case: + url = ( + f"https://discordapp.com/channels/" + f"{self.ctx.guild.id}/{self.ctx.channel.id}/{self.ctx.message.id}" + ) + set_extra_calls.append(call("jump_to", url)) + + push_scope_mock.set_tag.has_calls(set_tag_calls) + push_scope_mock.set_extra.has_calls(set_extra_calls) + class OtherErrorHandlerTests(unittest.IsolatedAsyncioTestCase): """Other `ErrorHandler` tests.""" -- cgit v1.2.3 From 09820f5b4a55d6240a05f848ea446bd46062f444 Mon Sep 17 00:00:00 2001 From: Andi Qu Date: Sun, 5 Jul 2020 20:33:03 +0200 Subject: Added better support for GitHub/GitLab --- bot/__main__.py | 2 + bot/cogs/print_snippets.py | 200 +++++++++++++++++++++++++++++++++++++++++++++ bot/cogs/repo_widgets.py | 123 ++++++++++++++++++++++++++++ 3 files changed, 325 insertions(+) create mode 100644 bot/cogs/print_snippets.py create mode 100644 bot/cogs/repo_widgets.py diff --git a/bot/__main__.py b/bot/__main__.py index 4e0d4a111..1d415eb20 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -71,6 +71,8 @@ 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") +bot.load_extension("bot.cogs.print_snippets") +bot.load_extension("bot.cogs.repo_widgets") if constants.HelpChannels.enable: bot.load_extension("bot.cogs.help_channels") diff --git a/bot/cogs/print_snippets.py b/bot/cogs/print_snippets.py new file mode 100644 index 000000000..06c9d6cc1 --- /dev/null +++ b/bot/cogs/print_snippets.py @@ -0,0 +1,200 @@ +""" +Cog that prints out snippets to Discord + +Matches each message against a regex and prints the contents +of the first matched snippet url +""" + +import os +import re +import textwrap + +from discord import Message +from discord.ext.commands import Cog +import aiohttp + +from bot.bot import Bot + + +async def fetch_http(session: aiohttp.ClientSession, url: str, response_format='text', **kwargs) -> str: + """Uses aiohttp to make http GET requests""" + + async with session.get(url, **kwargs) as response: + if response_format == 'text': + return await response.text() + elif response_format == 'json': + return await response.json() + + +async def revert_to_orig(d: dict) -> dict: + """Replace URL Encoded values back to their original""" + + for obj in d: + if d[obj] is not None: + d[obj] = d[obj].replace('%2F', '/').replace('%2E', '.') + + +async def orig_to_encode(d: dict) -> dict: + """Encode URL Parameters""" + + for obj in d: + if d[obj] is not None: + d[obj] = d[obj].replace('/', '%2F').replace('.', '%2E') + + +async def snippet_to_embed(d: dict, file_contents: str) -> str: + """ + Given a regex groupdict and file contents, creates a code block + """ + + if d['end_line']: + start_line = int(d['start_line']) + end_line = int(d['end_line']) + else: + start_line = end_line = int(d['start_line']) + + split_file_contents = file_contents.split('\n') + + if start_line > end_line: + start_line, end_line = end_line, start_line + if start_line > len(split_file_contents) or end_line < 1: + return '' + start_line = max(1, start_line) + end_line = min(len(split_file_contents), end_line) + + required = '\n'.join(split_file_contents[start_line - 1:end_line]) + required = textwrap.dedent(required).rstrip().replace('`', '`\u200b') + + language = d['file_path'].split('/')[-1].split('.')[-1] + if not language.replace('-', '').replace('+', '').replace('_', '').isalnum(): + language = '' + + if len(required) != 0: + return f'```{language}\n{required}```\n' + return '``` ```\n' + + +GITHUB_RE = re.compile( + r'https://github\.com/(?P.+?)/blob/(?P.+?)/' + + r'(?P.+?)#L(?P\d+)([-~]L(?P\d+))?\b' +) + +GITHUB_GIST_RE = re.compile( + r'https://gist\.github\.com/([^/]*)/(?P[0-9a-zA-Z]+)/*' + + r'(?P[0-9a-zA-Z]*)/*#file-(?P.+?)' + + r'-L(?P\d+)([-~]L(?P\d+))?\b' +) + +GITLAB_RE = re.compile( + r'https://gitlab\.com/(?P.+?)/\-/blob/(?P.+?)/' + + r'(?P.+?)#L(?P\d+)([-~](?P\d+))?\b' +) + +BITBUCKET_RE = re.compile( + r'https://bitbucket\.org/(?P.+?)/src/(?P.+?)/' + + r'(?P.+?)#lines-(?P\d+)(:(?P\d+))?\b' +) + + +class PrintSnippets(Cog): + def __init__(self, bot): + """Initializes the cog's bot""" + + self.bot = bot + self.session = aiohttp.ClientSession() + + @Cog.listener() + async def on_message(self, message: Message) -> None: + """ + Checks if the message starts is a GitHub snippet, then removes the embed, + then sends the snippet in Discord + """ + + gh_match = GITHUB_RE.search(message.content) + gh_gist_match = GITHUB_GIST_RE.search(message.content) + gl_match = GITLAB_RE.search(message.content) + bb_match = BITBUCKET_RE.search(message.content) + + if (gh_match or gh_gist_match or gl_match or bb_match) and not message.author.bot: + message_to_send = '' + + for gh in GITHUB_RE.finditer(message.content): + d = gh.groupdict() + headers = {'Accept': 'application/vnd.github.v3.raw'} + if 'GITHUB_TOKEN' in os.environ: + headers['Authorization'] = f'token {os.environ["GITHUB_TOKEN"]}' + file_contents = await fetch_http( + self.session, + f'https://api.github.com/repos/{d["repo"]}/contents/{d["file_path"]}?ref={d["branch"]}', + 'text', + headers=headers, + ) + message_to_send += await snippet_to_embed(d, file_contents) + + for gh_gist in GITHUB_GIST_RE.finditer(message.content): + d = gh_gist.groupdict() + gist_json = await fetch_http( + self.session, + f'https://api.github.com/gists/{d["gist_id"]}{"/" + d["revision"] if len(d["revision"]) > 0 else ""}', + 'json', + ) + for f in gist_json['files']: + if d['file_path'] == f.lower().replace('.', '-'): + d['file_path'] = f + file_contents = await fetch_http( + self.session, + gist_json['files'][f]['raw_url'], + 'text', + ) + message_to_send += await snippet_to_embed(d, file_contents) + break + + for gl in GITLAB_RE.finditer(message.content): + d = gl.groupdict() + await orig_to_encode(d) + headers = {} + if 'GITLAB_TOKEN' in os.environ: + headers['PRIVATE-TOKEN'] = os.environ["GITLAB_TOKEN"] + file_contents = await fetch_http( + self.session, + f'https://gitlab.com/api/v4/projects/{d["repo"]}/repository/files/{d["file_path"]}/raw?ref={d["branch"]}', + 'text', + headers=headers, + ) + await revert_to_orig(d) + message_to_send += await snippet_to_embed(d, file_contents) + + for bb in BITBUCKET_RE.finditer(message.content): + d = bb.groupdict() + await orig_to_encode(d) + file_contents = await fetch_http( + self.session, + f'https://bitbucket.org/{d["repo"]}/raw/{d["branch"]}/{d["file_path"]}', + 'text', + ) + await revert_to_orig(d) + message_to_send += await snippet_to_embed(d, file_contents) + + message_to_send = message_to_send[:-1] + + if len(message_to_send) > 2000: + await message.channel.send( + 'Sorry, Discord has a 2000 character limit. Please send a shorter ' + + 'snippet or split the big snippet up into several smaller ones :slight_smile:' + ) + elif len(message_to_send) == 0: + await message.channel.send( + 'Please send valid snippet links to prevent spam :slight_smile:' + ) + elif message_to_send.count('\n') > 50: + await message.channel.send( + 'Please limit the total number of lines to at most 50 to prevent spam :slight_smile:' + ) + else: + await message.channel.send(message_to_send) + await message.edit(suppress=True) + + +def setup(bot: Bot) -> None: + """Load the Utils cog.""" + bot.add_cog(PrintSnippets(bot)) diff --git a/bot/cogs/repo_widgets.py b/bot/cogs/repo_widgets.py new file mode 100644 index 000000000..70ca387ec --- /dev/null +++ b/bot/cogs/repo_widgets.py @@ -0,0 +1,123 @@ +""" +Cog that sends pretty embeds of repos + +Matches each message against a regex and prints the contents +of the first matched snippet url +""" + +import os +import re + +from discord import Embed, Message +from discord.ext.commands import Cog +import aiohttp + +from bot.bot import Bot + + +async def fetch_http(session: aiohttp.ClientSession, url: str, response_format='text', **kwargs) -> str: + """Uses aiohttp to make http GET requests""" + + async with session.get(url, **kwargs) as response: + if response_format == 'text': + return await response.text() + elif response_format == 'json': + return await response.json() + + +async def orig_to_encode(d: dict) -> dict: + """Encode URL Parameters""" + + for obj in d: + if d[obj] is not None: + d[obj] = d[obj].replace('/', '%2F').replace('.', '%2E') + + +GITHUB_RE = re.compile( + r'https://github\.com/(?P[^/]+?)/(?P[^/]+?)(?:\s|$)') + +GITLAB_RE = re.compile( + r'https://gitlab\.com/(?P[^/]+?)/(?P[^/]+?)(?:\s|$)') + + +class RepoWidgets(Cog): + def __init__(self, bot: Bot): + """Initializes the cog's bot""" + + self.bot = bot + self.session = aiohttp.ClientSession() + + @Cog.listener() + async def on_message(self, message: Message) -> None: + """ + Checks if the message starts is a GitHub repo link, then removes the embed, + then sends a rich embed to Discord + """ + + gh_match = GITHUB_RE.search(message.content) + gl_match = GITLAB_RE.search(message.content) + + if (gh_match or gl_match) and not message.author.bot: + for gh in GITHUB_RE.finditer(message.content): + d = gh.groupdict() + headers = {} + if 'GITHUB_TOKEN' in os.environ: + headers['Authorization'] = f'token {os.environ["GITHUB_TOKEN"]}' + repo = await fetch_http( + self.session, + f'https://api.github.com/repos/{d["owner"]}/{d["repo"]}', + 'json', + headers=headers, + ) + + embed = Embed( + title=repo['full_name'], + description='No description provided' if repo[ + 'description'] is None else repo['description'], + url=repo['html_url'], + color=0x111111 + ).set_footer( + text=f'Language: {repo["language"]} | ' + + f'Stars: {repo["stargazers_count"]} | ' + + f'Forks: {repo["forks_count"]} | ' + + f'Size: {repo["size"]}kb' + ).set_thumbnail(url=repo['owner']['avatar_url']) + if repo['homepage']: + embed.add_field(name='Website', value=repo['homepage']) + await message.channel.send(embed=embed) + + for gl in GITLAB_RE.finditer(message.content): + d = gl.groupdict() + await orig_to_encode(d) + headers = {} + if 'GITLAB_TOKEN' in os.environ: + headers['PRIVATE-TOKEN'] = os.environ["GITLAB_TOKEN"] + repo = await fetch_http( + self.session, + f'https://gitlab.com/api/v4/projects/{d["owner"]}%2F{d["repo"]}', + 'json', + headers=headers, + ) + + embed = Embed( + title=repo['path_with_namespace'], + description='No description provided' if repo[ + 'description'] == "" else repo['description'], + url=repo['web_url'], + color=0x111111 + ).set_footer( + text=f'Stars: {repo["star_count"]} | ' + + f'Forks: {repo["forks_count"]}' + ) + + if repo['avatar_url'] is not None: + embed.set_thumbnail(url=repo['avatar_url']) + + await message.channel.send(embed=embed) + + await message.edit(suppress=True) + + +def setup(bot: Bot) -> None: + """Load the Utils cog.""" + bot.add_cog(RepoWidgets(bot)) -- cgit v1.2.3 From 668d96e12acd76c5021ede07401cdb6062b89add Mon Sep 17 00:00:00 2001 From: Andi Qu Date: Sun, 5 Jul 2020 20:49:46 +0200 Subject: Tried to fix some of the flake8 style errors --- bot/cogs/print_snippets.py | 43 +++++++++++++++++-------------------------- bot/cogs/repo_widgets.py | 26 +++++++++----------------- 2 files changed, 26 insertions(+), 43 deletions(-) diff --git a/bot/cogs/print_snippets.py b/bot/cogs/print_snippets.py index 06c9d6cc1..4be3653d5 100644 --- a/bot/cogs/print_snippets.py +++ b/bot/cogs/print_snippets.py @@ -1,24 +1,16 @@ -""" -Cog that prints out snippets to Discord - -Matches each message against a regex and prints the contents -of the first matched snippet url -""" - import os import re import textwrap +import aiohttp from discord import Message from discord.ext.commands import Cog -import aiohttp from bot.bot import Bot -async def fetch_http(session: aiohttp.ClientSession, url: str, response_format='text', **kwargs) -> str: +async def fetch_http(session: aiohttp.ClientSession, url: str, response_format: str, **kwargs) -> str: """Uses aiohttp to make http GET requests""" - async with session.get(url, **kwargs) as response: if response_format == 'text': return await response.text() @@ -28,7 +20,6 @@ async def fetch_http(session: aiohttp.ClientSession, url: str, response_format=' async def revert_to_orig(d: dict) -> dict: """Replace URL Encoded values back to their original""" - for obj in d: if d[obj] is not None: d[obj] = d[obj].replace('%2F', '/').replace('%2E', '.') @@ -36,17 +27,13 @@ async def revert_to_orig(d: dict) -> dict: async def orig_to_encode(d: dict) -> dict: """Encode URL Parameters""" - for obj in d: if d[obj] is not None: d[obj] = d[obj].replace('/', '%2F').replace('.', '%2E') async def snippet_to_embed(d: dict, file_contents: str) -> str: - """ - Given a regex groupdict and file contents, creates a code block - """ - + """Given a regex groupdict and file contents, creates a code block""" if d['end_line']: start_line = int(d['start_line']) end_line = int(d['end_line']) @@ -97,19 +84,20 @@ BITBUCKET_RE = re.compile( class PrintSnippets(Cog): - def __init__(self, bot): - """Initializes the cog's bot""" + """ + Cog that prints out snippets to Discord + Matches each message against a regex and prints the contents of all matched snippets + """ + + def __init__(self, bot: Bot): + """Initializes the cog's bot""" self.bot = bot self.session = aiohttp.ClientSession() @Cog.listener() async def on_message(self, message: Message) -> None: - """ - Checks if the message starts is a GitHub snippet, then removes the embed, - then sends the snippet in Discord - """ - + """Checks if the message starts is a GitHub snippet, then removes the embed, then sends the snippet in Discord""" gh_match = GITHUB_RE.search(message.content) gh_gist_match = GITHUB_GIST_RE.search(message.content) gl_match = GITLAB_RE.search(message.content) @@ -125,7 +113,8 @@ class PrintSnippets(Cog): headers['Authorization'] = f'token {os.environ["GITHUB_TOKEN"]}' file_contents = await fetch_http( self.session, - f'https://api.github.com/repos/{d["repo"]}/contents/{d["file_path"]}?ref={d["branch"]}', + f'https://api.github.com/repos/{d["repo"]}\ + /contents/{d["file_path"]}?ref={d["branch"]}', 'text', headers=headers, ) @@ -135,7 +124,8 @@ class PrintSnippets(Cog): d = gh_gist.groupdict() gist_json = await fetch_http( self.session, - f'https://api.github.com/gists/{d["gist_id"]}{"/" + d["revision"] if len(d["revision"]) > 0 else ""}', + f'https://api.github.com/gists/{d["gist_id"]}\ + {"/" + d["revision"] if len(d["revision"]) > 0 else ""}', 'json', ) for f in gist_json['files']: @@ -157,7 +147,8 @@ class PrintSnippets(Cog): headers['PRIVATE-TOKEN'] = os.environ["GITLAB_TOKEN"] file_contents = await fetch_http( self.session, - f'https://gitlab.com/api/v4/projects/{d["repo"]}/repository/files/{d["file_path"]}/raw?ref={d["branch"]}', + f'https://gitlab.com/api/v4/projects/{d["repo"]}/\ + repository/files/{d["file_path"]}/raw?ref={d["branch"]}', 'text', headers=headers, ) diff --git a/bot/cogs/repo_widgets.py b/bot/cogs/repo_widgets.py index 70ca387ec..feb931e72 100644 --- a/bot/cogs/repo_widgets.py +++ b/bot/cogs/repo_widgets.py @@ -1,23 +1,15 @@ -""" -Cog that sends pretty embeds of repos - -Matches each message against a regex and prints the contents -of the first matched snippet url -""" - import os import re +import aiohttp from discord import Embed, Message from discord.ext.commands import Cog -import aiohttp from bot.bot import Bot -async def fetch_http(session: aiohttp.ClientSession, url: str, response_format='text', **kwargs) -> str: +async def fetch_http(session: aiohttp.ClientSession, url: str, response_format: str, **kwargs) -> str: """Uses aiohttp to make http GET requests""" - async with session.get(url, **kwargs) as response: if response_format == 'text': return await response.text() @@ -27,7 +19,6 @@ async def fetch_http(session: aiohttp.ClientSession, url: str, response_format=' async def orig_to_encode(d: dict) -> dict: """Encode URL Parameters""" - for obj in d: if d[obj] is not None: d[obj] = d[obj].replace('/', '%2F').replace('.', '%2E') @@ -41,19 +32,20 @@ GITLAB_RE = re.compile( class RepoWidgets(Cog): + """ + Cog that sends pretty embeds of repos + + Matches each message against a regex and sends an embed with the details of all referenced repos + """ + def __init__(self, bot: Bot): """Initializes the cog's bot""" - self.bot = bot self.session = aiohttp.ClientSession() @Cog.listener() async def on_message(self, message: Message) -> None: - """ - Checks if the message starts is a GitHub repo link, then removes the embed, - then sends a rich embed to Discord - """ - + """Checks if the message starts is a GitHub repo link, then removes the embed, then sends a rich embed to Discord""" gh_match = GITHUB_RE.search(message.content) gl_match = GITLAB_RE.search(message.content) -- cgit v1.2.3 From 2fe46fd372a5c8a69437e3f29c0137cb11d156d9 Mon Sep 17 00:00:00 2001 From: Andi Qu Date: Sun, 5 Jul 2020 20:54:55 +0200 Subject: Fixed all docstrings --- bot/cogs/print_snippets.py | 14 +++++++------- bot/cogs/repo_widgets.py | 20 ++++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/bot/cogs/print_snippets.py b/bot/cogs/print_snippets.py index 4be3653d5..5c83cd62b 100644 --- a/bot/cogs/print_snippets.py +++ b/bot/cogs/print_snippets.py @@ -10,7 +10,7 @@ from bot.bot import Bot async def fetch_http(session: aiohttp.ClientSession, url: str, response_format: str, **kwargs) -> str: - """Uses aiohttp to make http GET requests""" + """Uses aiohttp to make http GET requests.""" async with session.get(url, **kwargs) as response: if response_format == 'text': return await response.text() @@ -19,21 +19,21 @@ async def fetch_http(session: aiohttp.ClientSession, url: str, response_format: async def revert_to_orig(d: dict) -> dict: - """Replace URL Encoded values back to their original""" + """Replace URL Encoded values back to their original.""" for obj in d: if d[obj] is not None: d[obj] = d[obj].replace('%2F', '/').replace('%2E', '.') async def orig_to_encode(d: dict) -> dict: - """Encode URL Parameters""" + """Encode URL Parameters.""" for obj in d: if d[obj] is not None: d[obj] = d[obj].replace('/', '%2F').replace('.', '%2E') async def snippet_to_embed(d: dict, file_contents: str) -> str: - """Given a regex groupdict and file contents, creates a code block""" + """Given a regex groupdict and file contents, creates a code block.""" if d['end_line']: start_line = int(d['start_line']) end_line = int(d['end_line']) @@ -85,9 +85,9 @@ BITBUCKET_RE = re.compile( class PrintSnippets(Cog): """ - Cog that prints out snippets to Discord + Cog that prints out snippets to Discord. - Matches each message against a regex and prints the contents of all matched snippets + Matches each message against a regex and prints the contents of all matched snippets. """ def __init__(self, bot: Bot): @@ -97,7 +97,7 @@ class PrintSnippets(Cog): @Cog.listener() async def on_message(self, message: Message) -> None: - """Checks if the message starts is a GitHub snippet, then removes the embed, then sends the snippet in Discord""" + """Checks if the message has a snippet link, removes the embed, then sends the snippet contents.""" gh_match = GITHUB_RE.search(message.content) gh_gist_match = GITHUB_GIST_RE.search(message.content) gl_match = GITLAB_RE.search(message.content) diff --git a/bot/cogs/repo_widgets.py b/bot/cogs/repo_widgets.py index feb931e72..c8fde7c8e 100644 --- a/bot/cogs/repo_widgets.py +++ b/bot/cogs/repo_widgets.py @@ -9,7 +9,7 @@ from bot.bot import Bot async def fetch_http(session: aiohttp.ClientSession, url: str, response_format: str, **kwargs) -> str: - """Uses aiohttp to make http GET requests""" + """Uses aiohttp to make http GET requests.""" async with session.get(url, **kwargs) as response: if response_format == 'text': return await response.text() @@ -18,7 +18,7 @@ async def fetch_http(session: aiohttp.ClientSession, url: str, response_format: async def orig_to_encode(d: dict) -> dict: - """Encode URL Parameters""" + """Encode URL Parameters.""" for obj in d: if d[obj] is not None: d[obj] = d[obj].replace('/', '%2F').replace('.', '%2E') @@ -33,19 +33,19 @@ GITLAB_RE = re.compile( class RepoWidgets(Cog): """ - Cog that sends pretty embeds of repos + Cog that sends pretty embeds of repos. - Matches each message against a regex and sends an embed with the details of all referenced repos + Matches each message against a regex and sends an embed with the details of all referenced repos. """ def __init__(self, bot: Bot): - """Initializes the cog's bot""" + """Initializes the cog's bot.""" self.bot = bot self.session = aiohttp.ClientSession() @Cog.listener() async def on_message(self, message: Message) -> None: - """Checks if the message starts is a GitHub repo link, then removes the embed, then sends a rich embed to Discord""" + """Checks if the message has a repo link, removes the embed, then sends a rich embed.""" gh_match = GITHUB_RE.search(message.content) gl_match = GITLAB_RE.search(message.content) @@ -69,10 +69,10 @@ class RepoWidgets(Cog): url=repo['html_url'], color=0x111111 ).set_footer( - text=f'Language: {repo["language"]} | ' + - f'Stars: {repo["stargazers_count"]} | ' + - f'Forks: {repo["forks_count"]} | ' + - f'Size: {repo["size"]}kb' + text=f'Language: {repo["language"]} | ' + + f'Stars: {repo["stargazers_count"]} | ' + + f'Forks: {repo["forks_count"]} | ' + + f'Size: {repo["size"]}kb' ).set_thumbnail(url=repo['owner']['avatar_url']) if repo['homepage']: embed.add_field(name='Website', value=repo['homepage']) -- cgit v1.2.3 From ec3cc1704c7678f6389ac5c0688be90697410bed Mon Sep 17 00:00:00 2001 From: Andi Qu Date: Sun, 5 Jul 2020 20:59:18 +0200 Subject: Minor style fixes --- bot/cogs/print_snippets.py | 2 +- bot/cogs/repo_widgets.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/print_snippets.py b/bot/cogs/print_snippets.py index 5c83cd62b..67d411a63 100644 --- a/bot/cogs/print_snippets.py +++ b/bot/cogs/print_snippets.py @@ -91,7 +91,7 @@ class PrintSnippets(Cog): """ def __init__(self, bot: Bot): - """Initializes the cog's bot""" + """Initializes the cog's bot.""" self.bot = bot self.session = aiohttp.ClientSession() diff --git a/bot/cogs/repo_widgets.py b/bot/cogs/repo_widgets.py index c8fde7c8e..32c2451df 100644 --- a/bot/cogs/repo_widgets.py +++ b/bot/cogs/repo_widgets.py @@ -98,8 +98,8 @@ class RepoWidgets(Cog): url=repo['web_url'], color=0x111111 ).set_footer( - text=f'Stars: {repo["star_count"]} | ' + - f'Forks: {repo["forks_count"]}' + text=f'Stars: {repo["star_count"]} | ' + + f'Forks: {repo["forks_count"]}' ) if repo['avatar_url'] is not None: -- cgit v1.2.3 From 5fb1203883a975d752d9c8b803bb8420ef0f7c60 Mon Sep 17 00:00:00 2001 From: Andi Qu Date: Tue, 7 Jul 2020 19:42:53 +0200 Subject: Removed repo widget prettification and added reaction to remove lines --- bot/__main__.py | 1 - bot/cogs/print_snippets.py | 45 +++++++++--------- bot/cogs/repo_widgets.py | 115 --------------------------------------------- 3 files changed, 22 insertions(+), 139 deletions(-) delete mode 100644 bot/cogs/repo_widgets.py diff --git a/bot/__main__.py b/bot/__main__.py index 1d415eb20..3191faf85 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -72,7 +72,6 @@ bot.load_extension("bot.cogs.watchchannels") bot.load_extension("bot.cogs.webhook_remover") bot.load_extension("bot.cogs.wolfram") bot.load_extension("bot.cogs.print_snippets") -bot.load_extension("bot.cogs.repo_widgets") if constants.HelpChannels.enable: bot.load_extension("bot.cogs.help_channels") diff --git a/bot/cogs/print_snippets.py b/bot/cogs/print_snippets.py index 67d411a63..3f784d2c6 100644 --- a/bot/cogs/print_snippets.py +++ b/bot/cogs/print_snippets.py @@ -1,9 +1,10 @@ +import asyncio import os import re import textwrap import aiohttp -from discord import Message +from discord import Message, Reaction, User from discord.ext.commands import Cog from bot.bot import Bot @@ -113,8 +114,8 @@ class PrintSnippets(Cog): headers['Authorization'] = f'token {os.environ["GITHUB_TOKEN"]}' file_contents = await fetch_http( self.session, - f'https://api.github.com/repos/{d["repo"]}\ - /contents/{d["file_path"]}?ref={d["branch"]}', + f'https://api.github.com/repos/{d["repo"]}' + + f'/contents/{d["file_path"]}?ref={d["branch"]}', 'text', headers=headers, ) @@ -124,8 +125,8 @@ class PrintSnippets(Cog): d = gh_gist.groupdict() gist_json = await fetch_http( self.session, - f'https://api.github.com/gists/{d["gist_id"]}\ - {"/" + d["revision"] if len(d["revision"]) > 0 else ""}', + f'https://api.github.com/gists/{d["gist_id"]}' + + f'{"/" + d["revision"] if len(d["revision"]) > 0 else ""}', 'json', ) for f in gist_json['files']: @@ -147,8 +148,8 @@ class PrintSnippets(Cog): headers['PRIVATE-TOKEN'] = os.environ["GITLAB_TOKEN"] file_contents = await fetch_http( self.session, - f'https://gitlab.com/api/v4/projects/{d["repo"]}/\ - repository/files/{d["file_path"]}/raw?ref={d["branch"]}', + f'https://gitlab.com/api/v4/projects/{d["repo"]}/' + + f'repository/files/{d["file_path"]}/raw?ref={d["branch"]}', 'text', headers=headers, ) @@ -168,22 +169,20 @@ class PrintSnippets(Cog): message_to_send = message_to_send[:-1] - if len(message_to_send) > 2000: - await message.channel.send( - 'Sorry, Discord has a 2000 character limit. Please send a shorter ' - + 'snippet or split the big snippet up into several smaller ones :slight_smile:' - ) - elif len(message_to_send) == 0: - await message.channel.send( - 'Please send valid snippet links to prevent spam :slight_smile:' - ) - elif message_to_send.count('\n') > 50: - await message.channel.send( - 'Please limit the total number of lines to at most 50 to prevent spam :slight_smile:' - ) - else: - await message.channel.send(message_to_send) - await message.edit(suppress=True) + if 0 < len(message_to_send) <= 2000 and message_to_send.count('\n') <= 50: + sent_message = await message.channel.send(message_to_send) + await message.edit(suppress=True) + await sent_message.add_reaction('❌') + + def check(reaction: Reaction, user: User) -> bool: + return user == message.author and str(reaction.emoji) == '❌' + + try: + reaction, user = await self.bot.wait_for('reaction_add', timeout=10.0, check=check) + except asyncio.TimeoutError: + await sent_message.remove_reaction('❌', self.bot.user) + else: + await sent_message.delete() def setup(bot: Bot) -> None: diff --git a/bot/cogs/repo_widgets.py b/bot/cogs/repo_widgets.py deleted file mode 100644 index 32c2451df..000000000 --- a/bot/cogs/repo_widgets.py +++ /dev/null @@ -1,115 +0,0 @@ -import os -import re - -import aiohttp -from discord import Embed, Message -from discord.ext.commands import Cog - -from bot.bot import Bot - - -async def fetch_http(session: aiohttp.ClientSession, url: str, response_format: str, **kwargs) -> str: - """Uses aiohttp to make http GET requests.""" - async with session.get(url, **kwargs) as response: - if response_format == 'text': - return await response.text() - elif response_format == 'json': - return await response.json() - - -async def orig_to_encode(d: dict) -> dict: - """Encode URL Parameters.""" - for obj in d: - if d[obj] is not None: - d[obj] = d[obj].replace('/', '%2F').replace('.', '%2E') - - -GITHUB_RE = re.compile( - r'https://github\.com/(?P[^/]+?)/(?P[^/]+?)(?:\s|$)') - -GITLAB_RE = re.compile( - r'https://gitlab\.com/(?P[^/]+?)/(?P[^/]+?)(?:\s|$)') - - -class RepoWidgets(Cog): - """ - Cog that sends pretty embeds of repos. - - Matches each message against a regex and sends an embed with the details of all referenced repos. - """ - - def __init__(self, bot: Bot): - """Initializes the cog's bot.""" - self.bot = bot - self.session = aiohttp.ClientSession() - - @Cog.listener() - async def on_message(self, message: Message) -> None: - """Checks if the message has a repo link, removes the embed, then sends a rich embed.""" - gh_match = GITHUB_RE.search(message.content) - gl_match = GITLAB_RE.search(message.content) - - if (gh_match or gl_match) and not message.author.bot: - for gh in GITHUB_RE.finditer(message.content): - d = gh.groupdict() - headers = {} - if 'GITHUB_TOKEN' in os.environ: - headers['Authorization'] = f'token {os.environ["GITHUB_TOKEN"]}' - repo = await fetch_http( - self.session, - f'https://api.github.com/repos/{d["owner"]}/{d["repo"]}', - 'json', - headers=headers, - ) - - embed = Embed( - title=repo['full_name'], - description='No description provided' if repo[ - 'description'] is None else repo['description'], - url=repo['html_url'], - color=0x111111 - ).set_footer( - text=f'Language: {repo["language"]} | ' - + f'Stars: {repo["stargazers_count"]} | ' - + f'Forks: {repo["forks_count"]} | ' - + f'Size: {repo["size"]}kb' - ).set_thumbnail(url=repo['owner']['avatar_url']) - if repo['homepage']: - embed.add_field(name='Website', value=repo['homepage']) - await message.channel.send(embed=embed) - - for gl in GITLAB_RE.finditer(message.content): - d = gl.groupdict() - await orig_to_encode(d) - headers = {} - if 'GITLAB_TOKEN' in os.environ: - headers['PRIVATE-TOKEN'] = os.environ["GITLAB_TOKEN"] - repo = await fetch_http( - self.session, - f'https://gitlab.com/api/v4/projects/{d["owner"]}%2F{d["repo"]}', - 'json', - headers=headers, - ) - - embed = Embed( - title=repo['path_with_namespace'], - description='No description provided' if repo[ - 'description'] == "" else repo['description'], - url=repo['web_url'], - color=0x111111 - ).set_footer( - text=f'Stars: {repo["star_count"]} | ' - + f'Forks: {repo["forks_count"]}' - ) - - if repo['avatar_url'] is not None: - embed.set_thumbnail(url=repo['avatar_url']) - - await message.channel.send(embed=embed) - - await message.edit(suppress=True) - - -def setup(bot: Bot) -> None: - """Load the Utils cog.""" - bot.add_cog(RepoWidgets(bot)) -- cgit v1.2.3 From b759a940a097effd16b761e0c62231ae0ca9562b Mon Sep 17 00:00:00 2001 From: dolphingarlic Date: Thu, 30 Jul 2020 20:13:15 +0200 Subject: Cleaned the code for CodeSnippets --- bot/__main__.py | 2 +- bot/cogs/code_snippets.py | 216 +++++++++++++++++++++++++++++++++++++++++++++ bot/cogs/print_snippets.py | 190 --------------------------------------- 3 files changed, 217 insertions(+), 191 deletions(-) create mode 100644 bot/cogs/code_snippets.py delete mode 100644 bot/cogs/print_snippets.py diff --git a/bot/__main__.py b/bot/__main__.py index 3191faf85..3d414c4b8 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -71,7 +71,7 @@ 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") -bot.load_extension("bot.cogs.print_snippets") +bot.load_extension("bot.cogs.code_snippets") if constants.HelpChannels.enable: bot.load_extension("bot.cogs.help_channels") diff --git a/bot/cogs/code_snippets.py b/bot/cogs/code_snippets.py new file mode 100644 index 000000000..9bd06f6ff --- /dev/null +++ b/bot/cogs/code_snippets.py @@ -0,0 +1,216 @@ +import re +import textwrap +from urllib.parse import quote_plus + +from aiohttp import ClientSession +from discord import Message +from discord.ext.commands import Cog + +from bot.bot import Bot +from bot.utils.messages import wait_for_deletion + + +async def fetch_http(session: ClientSession, url: str, response_format: str, **kwargs) -> str: + """Uses aiohttp to make http GET requests.""" + async with session.get(url, **kwargs) as response: + if response_format == 'text': + return await response.text() + elif response_format == 'json': + return await response.json() + + +async def fetch_github_snippet(session: ClientSession, repo: str, + path: str, start_line: str, end_line: str) -> str: + """Fetches a snippet from a GitHub repo.""" + headers = {'Accept': 'application/vnd.github.v3.raw'} + + # Search the GitHub API for the specified branch + refs = (await fetch_http(session, f'https://api.github.com/repos/{repo}/branches', 'json', headers=headers) + + await fetch_http(session, f'https://api.github.com/repos/{repo}/tags', 'json', headers=headers)) + + ref = path.split('/')[0] + file_path = '/'.join(path.split('/')[1:]) + for possible_ref in refs: + if path.startswith(possible_ref['name'] + '/'): + ref = possible_ref['name'] + file_path = path[len(ref) + 1:] + break + + file_contents = await fetch_http( + session, + f'https://api.github.com/repos/{repo}/contents/{file_path}?ref={ref}', + 'text', + headers=headers, + ) + + return await snippet_to_md(file_contents, file_path, start_line, end_line) + + +async def fetch_github_gist_snippet(session: ClientSession, gist_id: str, revision: str, + file_path: str, start_line: str, end_line: str) -> str: + """Fetches a snippet from a GitHub gist.""" + headers = {'Accept': 'application/vnd.github.v3.raw'} + + gist_json = await fetch_http( + session, + f'https://api.github.com/gists/{gist_id}{f"/{revision}" if len(revision) > 0 else ""}', + 'json', + headers=headers, + ) + + # Check each file in the gist for the specified file + for gist_file in gist_json['files']: + if file_path == gist_file.lower().replace('.', '-'): + file_contents = await fetch_http( + session, + gist_json['files'][gist_file]['raw_url'], + 'text', + ) + + return await snippet_to_md(file_contents, gist_file, start_line, end_line) + + return '' + + +async def fetch_gitlab_snippet(session: ClientSession, repo: str, + path: str, start_line: str, end_line: str) -> str: + """Fetches a snippet from a GitLab repo.""" + enc_repo = quote_plus(repo) + + # Searches the GitLab API for the specified branch + refs = (await fetch_http(session, f'https://gitlab.com/api/v4/projects/{enc_repo}/repository/branches', 'json') + + await fetch_http(session, f'https://gitlab.com/api/v4/projects/{enc_repo}/repository/tags', 'json')) + + ref = path.split('/')[0] + file_path = '/'.join(path.split('/')[1:]) + for possible_ref in refs: + if path.startswith(possible_ref['name'] + '/'): + ref = possible_ref['name'] + file_path = path[len(ref) + 1:] + break + + enc_ref = quote_plus(ref) + enc_file_path = quote_plus(file_path) + + file_contents = await fetch_http( + session, + f'https://gitlab.com/api/v4/projects/{enc_repo}/repository/files/{enc_file_path}/raw?ref={enc_ref}', + 'text', + ) + + return await snippet_to_md(file_contents, file_path, start_line, end_line) + + +async def fetch_bitbucket_snippet(session: ClientSession, repo: str, ref: str, + file_path: str, start_line: int, end_line: int) -> str: + """Fetches a snippet from a BitBucket repo.""" + file_contents = await fetch_http( + session, + f'https://bitbucket.org/{quote_plus(repo)}/raw/{quote_plus(ref)}/{quote_plus(file_path)}', + 'text', + ) + + return await snippet_to_md(file_contents, file_path, start_line, end_line) + + +async def snippet_to_md(file_contents: str, file_path: str, start_line: str, end_line: str) -> str: + """Given file contents, file path, start line and end line creates a code block.""" + # Parse start_line and end_line into integers + if end_line is None: + start_line = end_line = int(start_line) + else: + start_line = int(start_line) + end_line = int(end_line) + + split_file_contents = file_contents.splitlines() + + # Make sure that the specified lines are in range + if start_line > end_line: + start_line, end_line = end_line, start_line + if start_line > len(split_file_contents) or end_line < 1: + return '' + start_line = max(1, start_line) + end_line = min(len(split_file_contents), end_line) + + # Gets the code lines, dedents them, and inserts zero-width spaces to prevent Markdown injection + required = '\n'.join(split_file_contents[start_line - 1:end_line]) + required = textwrap.dedent(required).rstrip().replace('`', '`\u200b') + + # Extracts the code language and checks whether it's a "valid" language + language = file_path.split('/')[-1].split('.')[-1] + if not language.replace('-', '').replace('+', '').replace('_', '').isalnum(): + language = '' + + if len(required) != 0: + return f'```{language}\n{required}```\n' + return '' + + +GITHUB_RE = re.compile( + r'https://github\.com/(?P.+?)/blob/(?P.+/.+)' + r'#L(?P\d+)([-~]L(?P\d+))?\b' +) + +GITHUB_GIST_RE = re.compile( + r'https://gist\.github\.com/([^/]+)/(?P[^\W_]+)/*' + r'(?P[^\W_]*)/*#file-(?P.+?)' + r'-L(?P\d+)([-~]L(?P\d+))?\b' +) + +GITLAB_RE = re.compile( + r'https://gitlab\.com/(?P.+?)/\-/blob/(?P.+/.+)' + r'#L(?P\d+)([-](?P\d+))?\b' +) + +BITBUCKET_RE = re.compile( + r'https://bitbucket\.org/(?P.+?)/src/(?P.+?)/' + r'(?P.+?)#lines-(?P\d+)(:(?P\d+))?\b' +) + + +class CodeSnippets(Cog): + """ + Cog that prints out snippets to Discord. + + Matches each message against a regex and prints the contents of all matched snippets. + """ + + def __init__(self, bot: Bot): + """Initializes the cog's bot.""" + self.bot = bot + + @Cog.listener() + async def on_message(self, message: Message) -> None: + """Checks if the message has a snippet link, removes the embed, then sends the snippet contents.""" + gh_match = GITHUB_RE.search(message.content) + gh_gist_match = GITHUB_GIST_RE.search(message.content) + gl_match = GITLAB_RE.search(message.content) + bb_match = BITBUCKET_RE.search(message.content) + + if (gh_match or gh_gist_match or gl_match or bb_match) and not message.author.bot: + message_to_send = '' + + for gh in GITHUB_RE.finditer(message.content): + message_to_send += await fetch_github_snippet(self.bot.http_session, **gh.groupdict()) + + for gh_gist in GITHUB_GIST_RE.finditer(message.content): + message_to_send += await fetch_github_gist_snippet(self.bot.http_session, **gh_gist.groupdict()) + + for gl in GITLAB_RE.finditer(message.content): + message_to_send += await fetch_gitlab_snippet(self.bot.http_session, **gl.groupdict()) + + for bb in BITBUCKET_RE.finditer(message.content): + message_to_send += await fetch_bitbucket_snippet(self.bot.http_session, **bb.groupdict()) + + if 0 < len(message_to_send) <= 2000 and message_to_send.count('\n') <= 15: + await message.edit(suppress=True) + await wait_for_deletion( + await message.channel.send(message_to_send), + (message.author.id,), + client=self.bot + ) + + +def setup(bot: Bot) -> None: + """Load the CodeSnippets cog.""" + bot.add_cog(CodeSnippets(bot)) diff --git a/bot/cogs/print_snippets.py b/bot/cogs/print_snippets.py deleted file mode 100644 index 3f784d2c6..000000000 --- a/bot/cogs/print_snippets.py +++ /dev/null @@ -1,190 +0,0 @@ -import asyncio -import os -import re -import textwrap - -import aiohttp -from discord import Message, Reaction, User -from discord.ext.commands import Cog - -from bot.bot import Bot - - -async def fetch_http(session: aiohttp.ClientSession, url: str, response_format: str, **kwargs) -> str: - """Uses aiohttp to make http GET requests.""" - async with session.get(url, **kwargs) as response: - if response_format == 'text': - return await response.text() - elif response_format == 'json': - return await response.json() - - -async def revert_to_orig(d: dict) -> dict: - """Replace URL Encoded values back to their original.""" - for obj in d: - if d[obj] is not None: - d[obj] = d[obj].replace('%2F', '/').replace('%2E', '.') - - -async def orig_to_encode(d: dict) -> dict: - """Encode URL Parameters.""" - for obj in d: - if d[obj] is not None: - d[obj] = d[obj].replace('/', '%2F').replace('.', '%2E') - - -async def snippet_to_embed(d: dict, file_contents: str) -> str: - """Given a regex groupdict and file contents, creates a code block.""" - if d['end_line']: - start_line = int(d['start_line']) - end_line = int(d['end_line']) - else: - start_line = end_line = int(d['start_line']) - - split_file_contents = file_contents.split('\n') - - if start_line > end_line: - start_line, end_line = end_line, start_line - if start_line > len(split_file_contents) or end_line < 1: - return '' - start_line = max(1, start_line) - end_line = min(len(split_file_contents), end_line) - - required = '\n'.join(split_file_contents[start_line - 1:end_line]) - required = textwrap.dedent(required).rstrip().replace('`', '`\u200b') - - language = d['file_path'].split('/')[-1].split('.')[-1] - if not language.replace('-', '').replace('+', '').replace('_', '').isalnum(): - language = '' - - if len(required) != 0: - return f'```{language}\n{required}```\n' - return '``` ```\n' - - -GITHUB_RE = re.compile( - r'https://github\.com/(?P.+?)/blob/(?P.+?)/' - + r'(?P.+?)#L(?P\d+)([-~]L(?P\d+))?\b' -) - -GITHUB_GIST_RE = re.compile( - r'https://gist\.github\.com/([^/]*)/(?P[0-9a-zA-Z]+)/*' - + r'(?P[0-9a-zA-Z]*)/*#file-(?P.+?)' - + r'-L(?P\d+)([-~]L(?P\d+))?\b' -) - -GITLAB_RE = re.compile( - r'https://gitlab\.com/(?P.+?)/\-/blob/(?P.+?)/' - + r'(?P.+?)#L(?P\d+)([-~](?P\d+))?\b' -) - -BITBUCKET_RE = re.compile( - r'https://bitbucket\.org/(?P.+?)/src/(?P.+?)/' - + r'(?P.+?)#lines-(?P\d+)(:(?P\d+))?\b' -) - - -class PrintSnippets(Cog): - """ - Cog that prints out snippets to Discord. - - Matches each message against a regex and prints the contents of all matched snippets. - """ - - def __init__(self, bot: Bot): - """Initializes the cog's bot.""" - self.bot = bot - self.session = aiohttp.ClientSession() - - @Cog.listener() - async def on_message(self, message: Message) -> None: - """Checks if the message has a snippet link, removes the embed, then sends the snippet contents.""" - gh_match = GITHUB_RE.search(message.content) - gh_gist_match = GITHUB_GIST_RE.search(message.content) - gl_match = GITLAB_RE.search(message.content) - bb_match = BITBUCKET_RE.search(message.content) - - if (gh_match or gh_gist_match or gl_match or bb_match) and not message.author.bot: - message_to_send = '' - - for gh in GITHUB_RE.finditer(message.content): - d = gh.groupdict() - headers = {'Accept': 'application/vnd.github.v3.raw'} - if 'GITHUB_TOKEN' in os.environ: - headers['Authorization'] = f'token {os.environ["GITHUB_TOKEN"]}' - file_contents = await fetch_http( - self.session, - f'https://api.github.com/repos/{d["repo"]}' - + f'/contents/{d["file_path"]}?ref={d["branch"]}', - 'text', - headers=headers, - ) - message_to_send += await snippet_to_embed(d, file_contents) - - for gh_gist in GITHUB_GIST_RE.finditer(message.content): - d = gh_gist.groupdict() - gist_json = await fetch_http( - self.session, - f'https://api.github.com/gists/{d["gist_id"]}' - + f'{"/" + d["revision"] if len(d["revision"]) > 0 else ""}', - 'json', - ) - for f in gist_json['files']: - if d['file_path'] == f.lower().replace('.', '-'): - d['file_path'] = f - file_contents = await fetch_http( - self.session, - gist_json['files'][f]['raw_url'], - 'text', - ) - message_to_send += await snippet_to_embed(d, file_contents) - break - - for gl in GITLAB_RE.finditer(message.content): - d = gl.groupdict() - await orig_to_encode(d) - headers = {} - if 'GITLAB_TOKEN' in os.environ: - headers['PRIVATE-TOKEN'] = os.environ["GITLAB_TOKEN"] - file_contents = await fetch_http( - self.session, - f'https://gitlab.com/api/v4/projects/{d["repo"]}/' - + f'repository/files/{d["file_path"]}/raw?ref={d["branch"]}', - 'text', - headers=headers, - ) - await revert_to_orig(d) - message_to_send += await snippet_to_embed(d, file_contents) - - for bb in BITBUCKET_RE.finditer(message.content): - d = bb.groupdict() - await orig_to_encode(d) - file_contents = await fetch_http( - self.session, - f'https://bitbucket.org/{d["repo"]}/raw/{d["branch"]}/{d["file_path"]}', - 'text', - ) - await revert_to_orig(d) - message_to_send += await snippet_to_embed(d, file_contents) - - message_to_send = message_to_send[:-1] - - if 0 < len(message_to_send) <= 2000 and message_to_send.count('\n') <= 50: - sent_message = await message.channel.send(message_to_send) - await message.edit(suppress=True) - await sent_message.add_reaction('❌') - - def check(reaction: Reaction, user: User) -> bool: - return user == message.author and str(reaction.emoji) == '❌' - - try: - reaction, user = await self.bot.wait_for('reaction_add', timeout=10.0, check=check) - except asyncio.TimeoutError: - await sent_message.remove_reaction('❌', self.bot.user) - else: - await sent_message.delete() - - -def setup(bot: Bot) -> None: - """Load the Utils cog.""" - bot.add_cog(PrintSnippets(bot)) -- cgit v1.2.3 From 8716902624668f2cef80656f5e9fae335a0559f2 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 27 Sep 2020 08:29:03 +0300 Subject: EH Tests: Fix order of imports --- tests/bot/exts/backend/test_error_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py index e8b9c7708..e3641ba21 100644 --- a/tests/bot/exts/backend/test_error_handler.py +++ b/tests/bot/exts/backend/test_error_handler.py @@ -4,10 +4,10 @@ from unittest.mock import AsyncMock, MagicMock, call, patch from discord.ext.commands import errors from bot.api import ResponseCodeError +from bot.decorators import InWhitelistCheckFailure from bot.exts.backend.error_handler import ErrorHandler, setup -from bot.exts.moderation.silence import Silence from bot.exts.info.tags import Tags -from bot.decorators import InWhitelistCheckFailure +from bot.exts.moderation.silence import Silence from tests.helpers import MockBot, MockContext, MockGuild -- cgit v1.2.3 From 5f250690523a4a1c631834d2a8e64898614c5932 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 27 Sep 2020 08:44:03 +0300 Subject: EH tests: Fix InWhitelistCheckFailure import path --- tests/bot/exts/backend/test_error_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py index e3641ba21..4a0adb03e 100644 --- a/tests/bot/exts/backend/test_error_handler.py +++ b/tests/bot/exts/backend/test_error_handler.py @@ -4,10 +4,10 @@ from unittest.mock import AsyncMock, MagicMock, call, patch from discord.ext.commands import errors from bot.api import ResponseCodeError -from bot.decorators import InWhitelistCheckFailure from bot.exts.backend.error_handler import ErrorHandler, setup from bot.exts.info.tags import Tags from bot.exts.moderation.silence import Silence +from bot.utils.checks import InWhitelistCheckFailure from tests.helpers import MockBot, MockContext, MockGuild -- cgit v1.2.3 From c966853e92b696b9132c6f5316e6920e3cb70733 Mon Sep 17 00:00:00 2001 From: Andi Qu Date: Tue, 27 Oct 2020 10:58:49 +0200 Subject: Moved code for finding the right ref to a function --- bot/cogs/code_snippets.py | 34 ++++++++++++++-------------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/bot/cogs/code_snippets.py b/bot/cogs/code_snippets.py index 9bd06f6ff..b10c68789 100644 --- a/bot/cogs/code_snippets.py +++ b/bot/cogs/code_snippets.py @@ -19,6 +19,18 @@ async def fetch_http(session: ClientSession, url: str, response_format: str, **k return await response.json() +def find_ref(path: str, refs: tuple) -> tuple: + """Loops through all branches and tags to find the required ref.""" + ref = path.split('/')[0] + file_path = '/'.join(path.split('/')[1:]) + for possible_ref in refs: + if path.startswith(possible_ref['name'] + '/'): + ref = possible_ref['name'] + file_path = path[len(ref) + 1:] + break + return (ref, file_path) + + async def fetch_github_snippet(session: ClientSession, repo: str, path: str, start_line: str, end_line: str) -> str: """Fetches a snippet from a GitHub repo.""" @@ -28,13 +40,7 @@ async def fetch_github_snippet(session: ClientSession, repo: str, refs = (await fetch_http(session, f'https://api.github.com/repos/{repo}/branches', 'json', headers=headers) + await fetch_http(session, f'https://api.github.com/repos/{repo}/tags', 'json', headers=headers)) - ref = path.split('/')[0] - file_path = '/'.join(path.split('/')[1:]) - for possible_ref in refs: - if path.startswith(possible_ref['name'] + '/'): - ref = possible_ref['name'] - file_path = path[len(ref) + 1:] - break + ref, file_path = find_ref(path, refs) file_contents = await fetch_http( session, @@ -42,7 +48,6 @@ async def fetch_github_snippet(session: ClientSession, repo: str, 'text', headers=headers, ) - return await snippet_to_md(file_contents, file_path, start_line, end_line) @@ -66,9 +71,7 @@ async def fetch_github_gist_snippet(session: ClientSession, gist_id: str, revisi gist_json['files'][gist_file]['raw_url'], 'text', ) - return await snippet_to_md(file_contents, gist_file, start_line, end_line) - return '' @@ -81,14 +84,7 @@ async def fetch_gitlab_snippet(session: ClientSession, repo: str, refs = (await fetch_http(session, f'https://gitlab.com/api/v4/projects/{enc_repo}/repository/branches', 'json') + await fetch_http(session, f'https://gitlab.com/api/v4/projects/{enc_repo}/repository/tags', 'json')) - ref = path.split('/')[0] - file_path = '/'.join(path.split('/')[1:]) - for possible_ref in refs: - if path.startswith(possible_ref['name'] + '/'): - ref = possible_ref['name'] - file_path = path[len(ref) + 1:] - break - + ref, file_path = find_ref(path, refs) enc_ref = quote_plus(ref) enc_file_path = quote_plus(file_path) @@ -97,7 +93,6 @@ async def fetch_gitlab_snippet(session: ClientSession, repo: str, f'https://gitlab.com/api/v4/projects/{enc_repo}/repository/files/{enc_file_path}/raw?ref={enc_ref}', 'text', ) - return await snippet_to_md(file_contents, file_path, start_line, end_line) @@ -109,7 +104,6 @@ async def fetch_bitbucket_snippet(session: ClientSession, repo: str, ref: str, f'https://bitbucket.org/{quote_plus(repo)}/raw/{quote_plus(ref)}/{quote_plus(file_path)}', 'text', ) - return await snippet_to_md(file_contents, file_path, start_line, end_line) -- cgit v1.2.3 From 372cfb9c1dcfb761ad468ac38955473db57f18b6 Mon Sep 17 00:00:00 2001 From: Andi Qu Date: Tue, 27 Oct 2020 11:02:03 +0200 Subject: Renamed fetch_http to fetch_response --- bot/cogs/code_snippets.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/bot/cogs/code_snippets.py b/bot/cogs/code_snippets.py index b10c68789..27faf70ec 100644 --- a/bot/cogs/code_snippets.py +++ b/bot/cogs/code_snippets.py @@ -10,8 +10,8 @@ from bot.bot import Bot from bot.utils.messages import wait_for_deletion -async def fetch_http(session: ClientSession, url: str, response_format: str, **kwargs) -> str: - """Uses aiohttp to make http GET requests.""" +async def fetch_response(session: ClientSession, url: str, response_format: str, **kwargs) -> str: + """Makes http requests using aiohttp.""" async with session.get(url, **kwargs) as response: if response_format == 'text': return await response.text() @@ -37,12 +37,12 @@ async def fetch_github_snippet(session: ClientSession, repo: str, headers = {'Accept': 'application/vnd.github.v3.raw'} # Search the GitHub API for the specified branch - refs = (await fetch_http(session, f'https://api.github.com/repos/{repo}/branches', 'json', headers=headers) - + await fetch_http(session, f'https://api.github.com/repos/{repo}/tags', 'json', headers=headers)) + refs = (await fetch_response(session, f'https://api.github.com/repos/{repo}/branches', 'json', headers=headers) + + await fetch_response(session, f'https://api.github.com/repos/{repo}/tags', 'json', headers=headers)) ref, file_path = find_ref(path, refs) - file_contents = await fetch_http( + file_contents = await fetch_response( session, f'https://api.github.com/repos/{repo}/contents/{file_path}?ref={ref}', 'text', @@ -56,7 +56,7 @@ async def fetch_github_gist_snippet(session: ClientSession, gist_id: str, revisi """Fetches a snippet from a GitHub gist.""" headers = {'Accept': 'application/vnd.github.v3.raw'} - gist_json = await fetch_http( + gist_json = await fetch_response( session, f'https://api.github.com/gists/{gist_id}{f"/{revision}" if len(revision) > 0 else ""}', 'json', @@ -66,7 +66,7 @@ async def fetch_github_gist_snippet(session: ClientSession, gist_id: str, revisi # Check each file in the gist for the specified file for gist_file in gist_json['files']: if file_path == gist_file.lower().replace('.', '-'): - file_contents = await fetch_http( + file_contents = await fetch_response( session, gist_json['files'][gist_file]['raw_url'], 'text', @@ -81,14 +81,14 @@ async def fetch_gitlab_snippet(session: ClientSession, repo: str, enc_repo = quote_plus(repo) # Searches the GitLab API for the specified branch - refs = (await fetch_http(session, f'https://gitlab.com/api/v4/projects/{enc_repo}/repository/branches', 'json') - + await fetch_http(session, f'https://gitlab.com/api/v4/projects/{enc_repo}/repository/tags', 'json')) + refs = (await fetch_response(session, f'https://gitlab.com/api/v4/projects/{enc_repo}/repository/branches', 'json') + + await fetch_response(session, f'https://gitlab.com/api/v4/projects/{enc_repo}/repository/tags', 'json')) ref, file_path = find_ref(path, refs) enc_ref = quote_plus(ref) enc_file_path = quote_plus(file_path) - file_contents = await fetch_http( + file_contents = await fetch_response( session, f'https://gitlab.com/api/v4/projects/{enc_repo}/repository/files/{enc_file_path}/raw?ref={enc_ref}', 'text', @@ -99,7 +99,7 @@ async def fetch_gitlab_snippet(session: ClientSession, repo: str, async def fetch_bitbucket_snippet(session: ClientSession, repo: str, ref: str, file_path: str, start_line: int, end_line: int) -> str: """Fetches a snippet from a BitBucket repo.""" - file_contents = await fetch_http( + file_contents = await fetch_response( session, f'https://bitbucket.org/{quote_plus(repo)}/raw/{quote_plus(ref)}/{quote_plus(file_path)}', 'text', -- cgit v1.2.3 From c3ce61937211cbd8c7e3df1c501cda70d97623cb Mon Sep 17 00:00:00 2001 From: Andi Qu Date: Tue, 27 Oct 2020 11:16:14 +0200 Subject: Renamed snippet_to_md and wrote a better docstring --- bot/cogs/code_snippets.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/bot/cogs/code_snippets.py b/bot/cogs/code_snippets.py index 27faf70ec..dda4d185f 100644 --- a/bot/cogs/code_snippets.py +++ b/bot/cogs/code_snippets.py @@ -21,8 +21,10 @@ async def fetch_response(session: ClientSession, url: str, response_format: str, def find_ref(path: str, refs: tuple) -> tuple: """Loops through all branches and tags to find the required ref.""" + # Base case: there is no slash in the branch name ref = path.split('/')[0] file_path = '/'.join(path.split('/')[1:]) + # In case there are slashes in the branch name, we loop through all branches and tags for possible_ref in refs: if path.startswith(possible_ref['name'] + '/'): ref = possible_ref['name'] @@ -48,7 +50,7 @@ async def fetch_github_snippet(session: ClientSession, repo: str, 'text', headers=headers, ) - return await snippet_to_md(file_contents, file_path, start_line, end_line) + return snippet_to_codeblock(file_contents, file_path, start_line, end_line) async def fetch_github_gist_snippet(session: ClientSession, gist_id: str, revision: str, @@ -71,7 +73,7 @@ async def fetch_github_gist_snippet(session: ClientSession, gist_id: str, revisi gist_json['files'][gist_file]['raw_url'], 'text', ) - return await snippet_to_md(file_contents, gist_file, start_line, end_line) + return snippet_to_codeblock(file_contents, gist_file, start_line, end_line) return '' @@ -93,7 +95,7 @@ async def fetch_gitlab_snippet(session: ClientSession, repo: str, f'https://gitlab.com/api/v4/projects/{enc_repo}/repository/files/{enc_file_path}/raw?ref={enc_ref}', 'text', ) - return await snippet_to_md(file_contents, file_path, start_line, end_line) + return snippet_to_codeblock(file_contents, file_path, start_line, end_line) async def fetch_bitbucket_snippet(session: ClientSession, repo: str, ref: str, @@ -104,11 +106,21 @@ async def fetch_bitbucket_snippet(session: ClientSession, repo: str, ref: str, f'https://bitbucket.org/{quote_plus(repo)}/raw/{quote_plus(ref)}/{quote_plus(file_path)}', 'text', ) - return await snippet_to_md(file_contents, file_path, start_line, end_line) + return snippet_to_codeblock(file_contents, file_path, start_line, end_line) -async def snippet_to_md(file_contents: str, file_path: str, start_line: str, end_line: str) -> str: - """Given file contents, file path, start line and end line creates a code block.""" +def snippet_to_codeblock(file_contents: str, file_path: str, start_line: str, end_line: str) -> str: + """ + Given the entire file contents and target lines, creates a code block. + + First, we split the file contents into a list of lines and then keep and join only the required + ones together. + + We then dedent the lines to look nice, and replace all ` characters with `\u200b to prevent + markdown injection. + + Finally, we surround the code with ``` characters. + """ # Parse start_line and end_line into integers if end_line is None: start_line = end_line = int(start_line) -- cgit v1.2.3 From 28dfd8278a8ee24fb26bc5359729ca0ed0307632 Mon Sep 17 00:00:00 2001 From: Andi Qu <31325319+dolphingarlic@users.noreply.github.com> Date: Tue, 27 Oct 2020 11:17:26 +0200 Subject: Update bot/cogs/code_snippets.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Leon Sandøy --- bot/cogs/code_snippets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/code_snippets.py b/bot/cogs/code_snippets.py index dda4d185f..d5424ea15 100644 --- a/bot/cogs/code_snippets.py +++ b/bot/cogs/code_snippets.py @@ -176,7 +176,7 @@ BITBUCKET_RE = re.compile( class CodeSnippets(Cog): """ - Cog that prints out snippets to Discord. + Cog that parses and sends code snippets to Discord. Matches each message against a regex and prints the contents of all matched snippets. """ -- cgit v1.2.3 From fd0bbdcd80156a443e5b91ad4b7f74e2c0285242 Mon Sep 17 00:00:00 2001 From: Andi Qu Date: Tue, 27 Oct 2020 11:19:56 +0200 Subject: Split up refs into branches and tags --- bot/cogs/code_snippets.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/cogs/code_snippets.py b/bot/cogs/code_snippets.py index dda4d185f..77c0ede42 100644 --- a/bot/cogs/code_snippets.py +++ b/bot/cogs/code_snippets.py @@ -39,9 +39,9 @@ async def fetch_github_snippet(session: ClientSession, repo: str, headers = {'Accept': 'application/vnd.github.v3.raw'} # Search the GitHub API for the specified branch - refs = (await fetch_response(session, f'https://api.github.com/repos/{repo}/branches', 'json', headers=headers) - + await fetch_response(session, f'https://api.github.com/repos/{repo}/tags', 'json', headers=headers)) - + branches = await fetch_response(session, f'https://api.github.com/repos/{repo}/branches', 'json', headers=headers) + tags = await fetch_response(session, f'https://api.github.com/repos/{repo}/tags', 'json', headers=headers) + refs = branches + tags ref, file_path = find_ref(path, refs) file_contents = await fetch_response( @@ -83,9 +83,9 @@ async def fetch_gitlab_snippet(session: ClientSession, repo: str, enc_repo = quote_plus(repo) # Searches the GitLab API for the specified branch - refs = (await fetch_response(session, f'https://gitlab.com/api/v4/projects/{enc_repo}/repository/branches', 'json') - + await fetch_response(session, f'https://gitlab.com/api/v4/projects/{enc_repo}/repository/tags', 'json')) - + branches = await fetch_response(session, f'https://api.github.com/repos/{repo}/branches', 'json') + tags = await fetch_response(session, f'https://api.github.com/repos/{repo}/tags', 'json') + refs = branches + tags ref, file_path = find_ref(path, refs) enc_ref = quote_plus(ref) enc_file_path = quote_plus(file_path) -- cgit v1.2.3 From 7807939084f01fed327ff2d1772fb81efc0edbba Mon Sep 17 00:00:00 2001 From: Andi Qu Date: Tue, 27 Oct 2020 15:34:52 +0200 Subject: Made check for valid language easier to read --- bot/exts/info/code_snippets.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 3d38ef1c3..c53c28e8b 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -144,7 +144,9 @@ def snippet_to_codeblock(file_contents: str, file_path: str, start_line: str, en # Extracts the code language and checks whether it's a "valid" language language = file_path.split('/')[-1].split('.')[-1] - if not language.replace('-', '').replace('+', '').replace('_', '').isalnum(): + trimmed_language = language.replace('-', '').replace('+', '').replace('_', '') + is_valid_language = trimmed_language.isalnum() + if not is_valid_language: language = '' if len(required) != 0: -- cgit v1.2.3 From 76afc563ac73f6b8d40194c15e28f42a9fe6be0f Mon Sep 17 00:00:00 2001 From: Andi Qu Date: Tue, 27 Oct 2020 15:45:09 +0200 Subject: Moved global functions into the cog and got rid of unnecessary aiohttp sessions --- bot/exts/info/code_snippets.py | 307 +++++++++++++++++++++-------------------- 1 file changed, 158 insertions(+), 149 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index c53c28e8b..12eb692d4 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -2,7 +2,6 @@ import re import textwrap from urllib.parse import quote_plus -from aiohttp import ClientSession from discord import Message from discord.ext.commands import Cog @@ -10,150 +9,6 @@ from bot.bot import Bot from bot.utils.messages import wait_for_deletion -async def fetch_response(session: ClientSession, url: str, response_format: str, **kwargs) -> str: - """Makes http requests using aiohttp.""" - async with session.get(url, **kwargs) as response: - if response_format == 'text': - return await response.text() - elif response_format == 'json': - return await response.json() - - -def find_ref(path: str, refs: tuple) -> tuple: - """Loops through all branches and tags to find the required ref.""" - # Base case: there is no slash in the branch name - ref = path.split('/')[0] - file_path = '/'.join(path.split('/')[1:]) - # In case there are slashes in the branch name, we loop through all branches and tags - for possible_ref in refs: - if path.startswith(possible_ref['name'] + '/'): - ref = possible_ref['name'] - file_path = path[len(ref) + 1:] - break - return (ref, file_path) - - -async def fetch_github_snippet(session: ClientSession, repo: str, - path: str, start_line: str, end_line: str) -> str: - """Fetches a snippet from a GitHub repo.""" - headers = {'Accept': 'application/vnd.github.v3.raw'} - - # Search the GitHub API for the specified branch - branches = await fetch_response(session, f'https://api.github.com/repos/{repo}/branches', 'json', headers=headers) - tags = await fetch_response(session, f'https://api.github.com/repos/{repo}/tags', 'json', headers=headers) - refs = branches + tags - ref, file_path = find_ref(path, refs) - - file_contents = await fetch_response( - session, - f'https://api.github.com/repos/{repo}/contents/{file_path}?ref={ref}', - 'text', - headers=headers, - ) - return snippet_to_codeblock(file_contents, file_path, start_line, end_line) - - -async def fetch_github_gist_snippet(session: ClientSession, gist_id: str, revision: str, - file_path: str, start_line: str, end_line: str) -> str: - """Fetches a snippet from a GitHub gist.""" - headers = {'Accept': 'application/vnd.github.v3.raw'} - - gist_json = await fetch_response( - session, - f'https://api.github.com/gists/{gist_id}{f"/{revision}" if len(revision) > 0 else ""}', - 'json', - headers=headers, - ) - - # Check each file in the gist for the specified file - for gist_file in gist_json['files']: - if file_path == gist_file.lower().replace('.', '-'): - file_contents = await fetch_response( - session, - gist_json['files'][gist_file]['raw_url'], - 'text', - ) - return snippet_to_codeblock(file_contents, gist_file, start_line, end_line) - return '' - - -async def fetch_gitlab_snippet(session: ClientSession, repo: str, - path: str, start_line: str, end_line: str) -> str: - """Fetches a snippet from a GitLab repo.""" - enc_repo = quote_plus(repo) - - # Searches the GitLab API for the specified branch - branches = await fetch_response(session, f'https://api.github.com/repos/{repo}/branches', 'json') - tags = await fetch_response(session, f'https://api.github.com/repos/{repo}/tags', 'json') - refs = branches + tags - ref, file_path = find_ref(path, refs) - enc_ref = quote_plus(ref) - enc_file_path = quote_plus(file_path) - - file_contents = await fetch_response( - session, - f'https://gitlab.com/api/v4/projects/{enc_repo}/repository/files/{enc_file_path}/raw?ref={enc_ref}', - 'text', - ) - return snippet_to_codeblock(file_contents, file_path, start_line, end_line) - - -async def fetch_bitbucket_snippet(session: ClientSession, repo: str, ref: str, - file_path: str, start_line: int, end_line: int) -> str: - """Fetches a snippet from a BitBucket repo.""" - file_contents = await fetch_response( - session, - f'https://bitbucket.org/{quote_plus(repo)}/raw/{quote_plus(ref)}/{quote_plus(file_path)}', - 'text', - ) - return snippet_to_codeblock(file_contents, file_path, start_line, end_line) - - -def snippet_to_codeblock(file_contents: str, file_path: str, start_line: str, end_line: str) -> str: - """ - Given the entire file contents and target lines, creates a code block. - - First, we split the file contents into a list of lines and then keep and join only the required - ones together. - - We then dedent the lines to look nice, and replace all ` characters with `\u200b to prevent - markdown injection. - - Finally, we surround the code with ``` characters. - """ - # Parse start_line and end_line into integers - if end_line is None: - start_line = end_line = int(start_line) - else: - start_line = int(start_line) - end_line = int(end_line) - - split_file_contents = file_contents.splitlines() - - # Make sure that the specified lines are in range - if start_line > end_line: - start_line, end_line = end_line, start_line - if start_line > len(split_file_contents) or end_line < 1: - return '' - start_line = max(1, start_line) - end_line = min(len(split_file_contents), end_line) - - # Gets the code lines, dedents them, and inserts zero-width spaces to prevent Markdown injection - required = '\n'.join(split_file_contents[start_line - 1:end_line]) - required = textwrap.dedent(required).rstrip().replace('`', '`\u200b') - - # Extracts the code language and checks whether it's a "valid" language - language = file_path.split('/')[-1].split('.')[-1] - trimmed_language = language.replace('-', '').replace('+', '').replace('_', '') - is_valid_language = trimmed_language.isalnum() - if not is_valid_language: - language = '' - - if len(required) != 0: - return f'```{language}\n{required}```\n' - return '' - - GITHUB_RE = re.compile( r'https://github\.com/(?P.+?)/blob/(?P.+/.+)' r'#L(?P\d+)([-~]L(?P\d+))?\b' @@ -183,6 +38,160 @@ class CodeSnippets(Cog): Matches each message against a regex and prints the contents of all matched snippets. """ + async def _fetch_response(self, url: str, response_format: str, **kwargs) -> str: + """Makes http requests using aiohttp.""" + async with self.bot.http_session.get(url, **kwargs) as response: + if response_format == 'text': + return await response.text() + elif response_format == 'json': + return await response.json() + + def _find_ref(self, path: str, refs: tuple) -> tuple: + """Loops through all branches and tags to find the required ref.""" + # Base case: there is no slash in the branch name + ref = path.split('/')[0] + file_path = '/'.join(path.split('/')[1:]) + # In case there are slashes in the branch name, we loop through all branches and tags + for possible_ref in refs: + if path.startswith(possible_ref['name'] + '/'): + ref = possible_ref['name'] + file_path = path[len(ref) + 1:] + break + return (ref, file_path) + + async def _fetch_github_snippet( + self, + repo: str, + path: str, + start_line: str, + end_line: str + ) -> str: + """Fetches a snippet from a GitHub repo.""" + headers = {'Accept': 'application/vnd.github.v3.raw'} + + # Search the GitHub API for the specified branch + branches = await self._fetch_response(f'https://api.github.com/repos/{repo}/branches', 'json', headers=headers) + tags = await self._fetch_response(f'https://api.github.com/repos/{repo}/tags', 'json', headers=headers) + refs = branches + tags + ref, file_path = self._find_ref(path, refs) + + file_contents = await self._fetch_response( + f'https://api.github.com/repos/{repo}/contents/{file_path}?ref={ref}', + 'text', + headers=headers, + ) + return self._snippet_to_codeblock(file_contents, file_path, start_line, end_line) + + async def _fetch_github_gist_snippet( + self, + gist_id: str, + revision: str, + file_path: str, + start_line: str, + end_line: str + ) -> str: + """Fetches a snippet from a GitHub gist.""" + headers = {'Accept': 'application/vnd.github.v3.raw'} + + gist_json = await self._fetch_response( + f'https://api.github.com/gists/{gist_id}{f"/{revision}" if len(revision) > 0 else ""}', + 'json', + headers=headers, + ) + + # Check each file in the gist for the specified file + for gist_file in gist_json['files']: + if file_path == gist_file.lower().replace('.', '-'): + file_contents = await self._fetch_response( + gist_json['files'][gist_file]['raw_url'], + 'text', + ) + return self._snippet_to_codeblock(file_contents, gist_file, start_line, end_line) + return '' + + async def _fetch_gitlab_snippet( + self, + repo: str, + path: str, + start_line: str, + end_line: str + ) -> str: + """Fetches a snippet from a GitLab repo.""" + enc_repo = quote_plus(repo) + + # Searches the GitLab API for the specified branch + branches = await self._fetch_response(f'https://api.github.com/repos/{repo}/branches', 'json') + tags = await self._fetch_response(f'https://api.github.com/repos/{repo}/tags', 'json') + refs = branches + tags + ref, file_path = self._find_ref(path, refs) + enc_ref = quote_plus(ref) + enc_file_path = quote_plus(file_path) + + file_contents = await self._fetch_response( + f'https://gitlab.com/api/v4/projects/{enc_repo}/repository/files/{enc_file_path}/raw?ref={enc_ref}', + 'text', + ) + return self._snippet_to_codeblock(file_contents, file_path, start_line, end_line) + + async def _fetch_bitbucket_snippet( + self, + repo: str, + ref: str, + file_path: str, + start_line: int, + end_line: int + ) -> str: + """Fetches a snippet from a BitBucket repo.""" + file_contents = await self._fetch_response( + f'https://bitbucket.org/{quote_plus(repo)}/raw/{quote_plus(ref)}/{quote_plus(file_path)}', + 'text', + ) + return self._snippet_to_codeblock(file_contents, file_path, start_line, end_line) + + def _snippet_to_codeblock(self, file_contents: str, file_path: str, start_line: str, end_line: str) -> str: + """ + Given the entire file contents and target lines, creates a code block. + + First, we split the file contents into a list of lines and then keep and join only the required + ones together. + + We then dedent the lines to look nice, and replace all ` characters with `\u200b to prevent + markdown injection. + + Finally, we surround the code with ``` characters. + """ + # Parse start_line and end_line into integers + if end_line is None: + start_line = end_line = int(start_line) + else: + start_line = int(start_line) + end_line = int(end_line) + + split_file_contents = file_contents.splitlines() + + # Make sure that the specified lines are in range + if start_line > end_line: + start_line, end_line = end_line, start_line + if start_line > len(split_file_contents) or end_line < 1: + return '' + start_line = max(1, start_line) + end_line = min(len(split_file_contents), end_line) + + # Gets the code lines, dedents them, and inserts zero-width spaces to prevent Markdown injection + required = '\n'.join(split_file_contents[start_line - 1:end_line]) + required = textwrap.dedent(required).rstrip().replace('`', '`\u200b') + + # Extracts the code language and checks whether it's a "valid" language + language = file_path.split('/')[-1].split('.')[-1] + trimmed_language = language.replace('-', '').replace('+', '').replace('_', '') + is_valid_language = trimmed_language.isalnum() + if not is_valid_language: + language = '' + + if len(required) != 0: + return f'```{language}\n{required}```\n' + return '' + def __init__(self, bot: Bot): """Initializes the cog's bot.""" self.bot = bot @@ -199,16 +208,16 @@ class CodeSnippets(Cog): message_to_send = '' for gh in GITHUB_RE.finditer(message.content): - message_to_send += await fetch_github_snippet(self.bot.http_session, **gh.groupdict()) + message_to_send += await self._fetch_github_snippet(**gh.groupdict()) for gh_gist in GITHUB_GIST_RE.finditer(message.content): - message_to_send += await fetch_github_gist_snippet(self.bot.http_session, **gh_gist.groupdict()) + message_to_send += await self._fetch_github_gist_snippet(**gh_gist.groupdict()) for gl in GITLAB_RE.finditer(message.content): - message_to_send += await fetch_gitlab_snippet(self.bot.http_session, **gl.groupdict()) + message_to_send += await self._fetch_gitlab_snippet(**gl.groupdict()) for bb in BITBUCKET_RE.finditer(message.content): - message_to_send += await fetch_bitbucket_snippet(self.bot.http_session, **bb.groupdict()) + message_to_send += await self._fetch_bitbucket_snippet(**bb.groupdict()) if 0 < len(message_to_send) <= 2000 and message_to_send.count('\n') <= 15: await message.edit(suppress=True) -- cgit v1.2.3 From 3102c698e8892d5a3b1b0fcc2183bf2c480d60fd Mon Sep 17 00:00:00 2001 From: Andi Qu Date: Tue, 27 Oct 2020 15:55:34 +0200 Subject: Used a list of tuples for on_message instead --- bot/exts/info/code_snippets.py | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 12eb692d4..1bb00b677 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -199,25 +199,18 @@ class CodeSnippets(Cog): @Cog.listener() async def on_message(self, message: Message) -> None: """Checks if the message has a snippet link, removes the embed, then sends the snippet contents.""" - gh_match = GITHUB_RE.search(message.content) - gh_gist_match = GITHUB_GIST_RE.search(message.content) - gl_match = GITLAB_RE.search(message.content) - bb_match = BITBUCKET_RE.search(message.content) - - if (gh_match or gh_gist_match or gl_match or bb_match) and not message.author.bot: + if not message.author.bot: message_to_send = '' - - for gh in GITHUB_RE.finditer(message.content): - message_to_send += await self._fetch_github_snippet(**gh.groupdict()) - - for gh_gist in GITHUB_GIST_RE.finditer(message.content): - message_to_send += await self._fetch_github_gist_snippet(**gh_gist.groupdict()) - - for gl in GITLAB_RE.finditer(message.content): - message_to_send += await self._fetch_gitlab_snippet(**gl.groupdict()) - - for bb in BITBUCKET_RE.finditer(message.content): - message_to_send += await self._fetch_bitbucket_snippet(**bb.groupdict()) + pattern_handlers = [ + (GITHUB_RE, self._fetch_github_snippet), + (GITHUB_GIST_RE, self._fetch_github_gist_snippet), + (GITLAB_RE, self._fetch_gitlab_snippet), + (BITBUCKET_RE, self._fetch_bitbucket_snippet) + ] + + for pattern, handler in pattern_handlers: + for match in pattern.finditer(message.content): + message_to_send += await handler(**match.groupdict()) if 0 < len(message_to_send) <= 2000 and message_to_send.count('\n') <= 15: await message.edit(suppress=True) -- cgit v1.2.3 From bbf7a600ca4b657258b46074c00cab1982791613 Mon Sep 17 00:00:00 2001 From: Andi Qu <31325319+dolphingarlic@users.noreply.github.com> Date: Wed, 28 Oct 2020 09:26:09 +0200 Subject: Update bot/exts/info/code_snippets.py Co-authored-by: Mark --- bot/exts/info/code_snippets.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 1bb00b677..4594c36f2 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -49,8 +49,7 @@ class CodeSnippets(Cog): def _find_ref(self, path: str, refs: tuple) -> tuple: """Loops through all branches and tags to find the required ref.""" # Base case: there is no slash in the branch name - ref = path.split('/')[0] - file_path = '/'.join(path.split('/')[1:]) + ref, file_path = path.split('/', 1) # In case there are slashes in the branch name, we loop through all branches and tags for possible_ref in refs: if path.startswith(possible_ref['name'] + '/'): -- cgit v1.2.3 From 1b8610c83dacfe1b19f3efa5d3a2b66c4c6e1e5d Mon Sep 17 00:00:00 2001 From: Andi Qu <31325319+dolphingarlic@users.noreply.github.com> Date: Wed, 28 Oct 2020 09:31:01 +0200 Subject: Removed unnecessary space before equals sign --- bot/exts/info/code_snippets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 4594c36f2..d854ebb4c 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -49,7 +49,7 @@ class CodeSnippets(Cog): def _find_ref(self, path: str, refs: tuple) -> tuple: """Loops through all branches and tags to find the required ref.""" # Base case: there is no slash in the branch name - ref, file_path = path.split('/', 1) + ref, file_path = path.split('/', 1) # In case there are slashes in the branch name, we loop through all branches and tags for possible_ref in refs: if path.startswith(possible_ref['name'] + '/'): -- cgit v1.2.3 From 8b41a7678d175de69ae6bf72e6a9f6e7036e1968 Mon Sep 17 00:00:00 2001 From: Andi Qu Date: Tue, 8 Dec 2020 10:21:41 +0200 Subject: Add file path to codeblock --- bot/exts/info/code_snippets.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 1bb00b677..f807fa9a7 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -188,9 +188,16 @@ class CodeSnippets(Cog): if not is_valid_language: language = '' + # Adds a label showing the file path to the snippet + if start_line == end_line: + ret = f'`{file_path}` line {start_line}\n' + else: + ret = f'`{file_path}` lines {start_line} to {end_line}\n' + if len(required) != 0: - return f'```{language}\n{required}```\n' - return '' + return f'{ret}```{language}\n{required}```\n' + # Returns an empty codeblock if the snippet is empty + return f'{ret}``` ```\n' def __init__(self, bot: Bot): """Initializes the cog's bot.""" -- cgit v1.2.3 From e8d2448c771aef262b294a583661092c9e90baef Mon Sep 17 00:00:00 2001 From: Andi Qu Date: Tue, 8 Dec 2020 10:36:56 +0200 Subject: Add logging for HTTP requests --- bot/exts/info/code_snippets.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index f807fa9a7..e1025e568 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -1,3 +1,4 @@ +import logging import re import textwrap from urllib.parse import quote_plus @@ -8,6 +9,7 @@ from discord.ext.commands import Cog from bot.bot import Bot from bot.utils.messages import wait_for_deletion +log = logging.getLogger(__name__) GITHUB_RE = re.compile( r'https://github\.com/(?P.+?)/blob/(?P.+/.+)' @@ -40,11 +42,14 @@ class CodeSnippets(Cog): async def _fetch_response(self, url: str, response_format: str, **kwargs) -> str: """Makes http requests using aiohttp.""" - async with self.bot.http_session.get(url, **kwargs) as response: - if response_format == 'text': - return await response.text() - elif response_format == 'json': - return await response.json() + try: + async with self.bot.http_session.get(url, **kwargs) as response: + if response_format == 'text': + return await response.text() + elif response_format == 'json': + return await response.json() + except Exception: + log.exception(f'Failed to fetch code snippet from {url}.') def _find_ref(self, path: str, refs: tuple) -> tuple: """Loops through all branches and tags to find the required ref.""" -- cgit v1.2.3 From d32e8f1029be8deb76e8c0d9bb457c9768ca878e Mon Sep 17 00:00:00 2001 From: Andi Qu Date: Wed, 13 Jan 2021 19:08:32 +0200 Subject: Better regex, moved pattern handlers to __init__, and constant header --- bot/exts/info/code_snippets.py | 52 +++++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 669a21c7d..1899b139b 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -12,24 +12,27 @@ from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) GITHUB_RE = re.compile( - r'https://github\.com/(?P.+?)/blob/(?P.+/.+)' - r'#L(?P\d+)([-~]L(?P\d+))?\b' + r'https://github\.com/(?P\S+?)/blob/(?P\S+/[^\s#]+)' + r'(#L(?P\d+)([-~:]L(?P\d+))?)?($|\s)' ) GITHUB_GIST_RE = re.compile( r'https://gist\.github\.com/([^/]+)/(?P[^\W_]+)/*' - r'(?P[^\W_]*)/*#file-(?P.+?)' - r'-L(?P\d+)([-~]L(?P\d+))?\b' + r'(?P[^\W_]*)/*#file-(?P\S+?)' + r'(-L(?P\d+)([-~:]L(?P\d+))?)?($|\s)' ) +GITHUB_HEADERS = {'Accept': 'application/vnd.github.v3.raw'} + GITLAB_RE = re.compile( - r'https://gitlab\.com/(?P.+?)/\-/blob/(?P.+/.+)' - r'#L(?P\d+)([-](?P\d+))?\b' + r'https://gitlab\.com/(?P\S+?)/\-/blob/(?P\S+/[^\s#]+)' + r'(#L(?P\d+)([-](?P\d+))?)?($|\s)' ) BITBUCKET_RE = re.compile( - r'https://bitbucket\.org/(?P.+?)/src/(?P.+?)/' - r'(?P.+?)#lines-(?P\d+)(:(?P\d+))?\b' + r'https://bitbucket\.org/(?P\S+?)/src/' + r'(?P\S+?)/(?P[^\s#]+)' + r'(#lines-(?P\d+)(:(?P\d+))?)?($|\s)' ) @@ -71,18 +74,20 @@ class CodeSnippets(Cog): end_line: str ) -> str: """Fetches a snippet from a GitHub repo.""" - headers = {'Accept': 'application/vnd.github.v3.raw'} - # Search the GitHub API for the specified branch - branches = await self._fetch_response(f'https://api.github.com/repos/{repo}/branches', 'json', headers=headers) - tags = await self._fetch_response(f'https://api.github.com/repos/{repo}/tags', 'json', headers=headers) + branches = await self._fetch_response( + f'https://api.github.com/repos/{repo}/branches', + 'json', + headers=GITHUB_HEADERS + ) + tags = await self._fetch_response(f'https://api.github.com/repos/{repo}/tags', 'json', headers=GITHUB_HEADERS) refs = branches + tags ref, file_path = self._find_ref(path, refs) file_contents = await self._fetch_response( f'https://api.github.com/repos/{repo}/contents/{file_path}?ref={ref}', 'text', - headers=headers, + headers=GITHUB_HEADERS, ) return self._snippet_to_codeblock(file_contents, file_path, start_line, end_line) @@ -95,12 +100,10 @@ class CodeSnippets(Cog): end_line: str ) -> str: """Fetches a snippet from a GitHub gist.""" - headers = {'Accept': 'application/vnd.github.v3.raw'} - gist_json = await self._fetch_response( f'https://api.github.com/gists/{gist_id}{f"/{revision}" if len(revision) > 0 else ""}', 'json', - headers=headers, + headers=GITHUB_HEADERS, ) # Check each file in the gist for the specified file @@ -207,19 +210,20 @@ class CodeSnippets(Cog): """Initializes the cog's bot.""" self.bot = bot + self.pattern_handlers = [ + (GITHUB_RE, self._fetch_github_snippet), + (GITHUB_GIST_RE, self._fetch_github_gist_snippet), + (GITLAB_RE, self._fetch_gitlab_snippet), + (BITBUCKET_RE, self._fetch_bitbucket_snippet) + ] + @Cog.listener() async def on_message(self, message: Message) -> None: """Checks if the message has a snippet link, removes the embed, then sends the snippet contents.""" if not message.author.bot: message_to_send = '' - pattern_handlers = [ - (GITHUB_RE, self._fetch_github_snippet), - (GITHUB_GIST_RE, self._fetch_github_gist_snippet), - (GITLAB_RE, self._fetch_gitlab_snippet), - (BITBUCKET_RE, self._fetch_bitbucket_snippet) - ] - - for pattern, handler in pattern_handlers: + + for pattern, handler in self.pattern_handlers: for match in pattern.finditer(message.content): message_to_send += await handler(**match.groupdict()) -- cgit v1.2.3 From 1856ed852515c17c2095c10b93d4d418787ec178 Mon Sep 17 00:00:00 2001 From: Andi Qu Date: Wed, 13 Jan 2021 19:10:03 +0200 Subject: Better regex now works for --- bot/exts/info/code_snippets.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 1899b139b..1d1bc2850 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -12,27 +12,27 @@ from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) GITHUB_RE = re.compile( - r'https://github\.com/(?P\S+?)/blob/(?P\S+/[^\s#]+)' - r'(#L(?P\d+)([-~:]L(?P\d+))?)?($|\s)' + r'https://github\.com/(?P\S+?)/blob/(?P\S+/[^\s#,>]+)' + r'(#L(?P\d+)([-~:]L(?P\d+))?)?($|\s|,|>)' ) GITHUB_GIST_RE = re.compile( r'https://gist\.github\.com/([^/]+)/(?P[^\W_]+)/*' r'(?P[^\W_]*)/*#file-(?P\S+?)' - r'(-L(?P\d+)([-~:]L(?P\d+))?)?($|\s)' + r'(-L(?P\d+)([-~:]L(?P\d+))?)?($|\s|,|>)' ) GITHUB_HEADERS = {'Accept': 'application/vnd.github.v3.raw'} GITLAB_RE = re.compile( - r'https://gitlab\.com/(?P\S+?)/\-/blob/(?P\S+/[^\s#]+)' - r'(#L(?P\d+)([-](?P\d+))?)?($|\s)' + r'https://gitlab\.com/(?P\S+?)/\-/blob/(?P\S+/[^\s#,>]+)' + r'(#L(?P\d+)([-](?P\d+))?)?($|\s|,|>)' ) BITBUCKET_RE = re.compile( r'https://bitbucket\.org/(?P\S+?)/src/' - r'(?P\S+?)/(?P[^\s#]+)' - r'(#lines-(?P\d+)(:(?P\d+))?)?($|\s)' + r'(?P\S+?)/(?P[^\s#,>]+)' + r'(#lines-(?P\d+)(:(?P\d+))?)?($|\s|,|>)' ) -- cgit v1.2.3 From 08b793024f271de009aab2391cd85576af5313cf Mon Sep 17 00:00:00 2001 From: Andi Qu Date: Wed, 13 Jan 2021 19:19:49 +0200 Subject: Better error reporting in _fetch_response(?) --- bot/exts/info/code_snippets.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 1d1bc2850..3469b88f4 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -3,6 +3,7 @@ import re import textwrap from urllib.parse import quote_plus +from aiohttp import ClientResponseError from discord import Message from discord.ext.commands import Cog @@ -46,13 +47,13 @@ class CodeSnippets(Cog): async def _fetch_response(self, url: str, response_format: str, **kwargs) -> str: """Makes http requests using aiohttp.""" try: - async with self.bot.http_session.get(url, **kwargs) as response: + async with self.bot.http_session.get(url, raise_for_status=True, **kwargs) as response: if response_format == 'text': return await response.text() elif response_format == 'json': return await response.json() - except Exception: - log.exception(f'Failed to fetch code snippet from {url}.') + except ClientResponseError as error: + log.error(f'Failed to fetch code snippet from {url}. HTTP Status: {error.status}. Message: {str(error)}.') def _find_ref(self, path: str, refs: tuple) -> tuple: """Loops through all branches and tags to find the required ref.""" -- cgit v1.2.3 From 318a0f6c5e597c61833984cd608359c8b4e5ddf0 Mon Sep 17 00:00:00 2001 From: Andi Qu Date: Tue, 19 Jan 2021 21:00:34 +0200 Subject: Better GitHub regex --- bot/exts/info/code_snippets.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 3469b88f4..84f606036 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -13,27 +13,27 @@ from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) GITHUB_RE = re.compile( - r'https://github\.com/(?P\S+?)/blob/(?P\S+/[^\s#,>]+)' - r'(#L(?P\d+)([-~:]L(?P\d+))?)?($|\s|,|>)' + r'https://github\.com/(?P[a-zA-Z0-9-]+/[\w.-]+)/blob/' + r'(?P[^#>]+/{0,1})(#L(?P\d+)([-~:]L(?P\d+))?)' ) GITHUB_GIST_RE = re.compile( r'https://gist\.github\.com/([^/]+)/(?P[^\W_]+)/*' r'(?P[^\W_]*)/*#file-(?P\S+?)' - r'(-L(?P\d+)([-~:]L(?P\d+))?)?($|\s|,|>)' + r'(-L(?P\d+)([-~:]L(?P\d+))?)' ) GITHUB_HEADERS = {'Accept': 'application/vnd.github.v3.raw'} GITLAB_RE = re.compile( r'https://gitlab\.com/(?P\S+?)/\-/blob/(?P\S+/[^\s#,>]+)' - r'(#L(?P\d+)([-](?P\d+))?)?($|\s|,|>)' + r'(#L(?P\d+)([-](?P\d+))?)' ) BITBUCKET_RE = re.compile( r'https://bitbucket\.org/(?P\S+?)/src/' r'(?P\S+?)/(?P[^\s#,>]+)' - r'(#lines-(?P\d+)(:(?P\d+))?)?($|\s|,|>)' + r'(#lines-(?P\d+)(:(?P\d+))?)' ) -- cgit v1.2.3 From e9f48d83d482502a846dd8d37cee6ab4c01fdf7e Mon Sep 17 00:00:00 2001 From: Andi Qu Date: Tue, 19 Jan 2021 21:14:19 +0200 Subject: Account for query params in bitbucket --- bot/exts/info/code_snippets.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 84f606036..75d8ac290 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -18,22 +18,21 @@ GITHUB_RE = re.compile( ) GITHUB_GIST_RE = re.compile( - r'https://gist\.github\.com/([^/]+)/(?P[^\W_]+)/*' - r'(?P[^\W_]*)/*#file-(?P\S+?)' + r'https://gist\.github\.com/([^/]+)/(?P[a-zA-Z0-9]+)/*' + r'(?P[a-zA-Z0-9-]*)/*#file-(?P[^#>]+?)' r'(-L(?P\d+)([-~:]L(?P\d+))?)' ) GITHUB_HEADERS = {'Accept': 'application/vnd.github.v3.raw'} GITLAB_RE = re.compile( - r'https://gitlab\.com/(?P\S+?)/\-/blob/(?P\S+/[^\s#,>]+)' - r'(#L(?P\d+)([-](?P\d+))?)' + r'https://gitlab\.com/(?P[a-zA-Z0-9-]+?)/\-/blob/(?P[^#>]+/{0,1})' + r'(#L(?P\d+)(-(?P\d+))?)' ) BITBUCKET_RE = re.compile( - r'https://bitbucket\.org/(?P\S+?)/src/' - r'(?P\S+?)/(?P[^\s#,>]+)' - r'(#lines-(?P\d+)(:(?P\d+))?)' + r'https://bitbucket\.org/(?P[a-zA-Z0-9-]+/[\w.-]+?)/src/(?P[0-9a-zA-Z]+?)' + r'/(?P[^#>]+?)(\?[^#>]+)?(#lines-(?P\d+)(:(?P\d+))?)' ) -- cgit v1.2.3 From 87facef69acfaa1d8b69b5a03bfabc9582aa1ace Mon Sep 17 00:00:00 2001 From: Andi Qu <31325319+dolphingarlic@users.noreply.github.com> Date: Sun, 24 Jan 2021 19:57:26 +0200 Subject: More restrictive GitHub gist regex for usernames --- bot/exts/info/code_snippets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 75d8ac290..e1b2079d0 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -18,7 +18,7 @@ GITHUB_RE = re.compile( ) GITHUB_GIST_RE = re.compile( - r'https://gist\.github\.com/([^/]+)/(?P[a-zA-Z0-9]+)/*' + r'https://gist\.github\.com/([a-zA-Z0-9-]+)/(?P[a-zA-Z0-9]+)/*' r'(?P[a-zA-Z0-9-]*)/*#file-(?P[^#>]+?)' r'(-L(?P\d+)([-~:]L(?P\d+))?)' ) -- cgit v1.2.3 From 69a87371aeaf815cea71d5b44a7b6a824f7fa5ed Mon Sep 17 00:00:00 2001 From: Andi Qu <31325319+dolphingarlic@users.noreply.github.com> Date: Sun, 24 Jan 2021 19:58:36 +0200 Subject: Don't match dashes in GitHub gist revisions Gist revisions don't allow dashes oops --- bot/exts/info/code_snippets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index e1b2079d0..44f11cdbd 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -19,7 +19,7 @@ GITHUB_RE = re.compile( GITHUB_GIST_RE = re.compile( r'https://gist\.github\.com/([a-zA-Z0-9-]+)/(?P[a-zA-Z0-9]+)/*' - r'(?P[a-zA-Z0-9-]*)/*#file-(?P[^#>]+?)' + r'(?P[a-zA-Z0-9]*)/*#file-(?P[^#>]+?)' r'(-L(?P\d+)([-~:]L(?P\d+))?)' ) -- cgit v1.2.3 From ae5e1c64983431e1bcac1fc9a50255fdc32777ee Mon Sep 17 00:00:00 2001 From: Andi Qu <31325319+dolphingarlic@users.noreply.github.com> Date: Sun, 24 Jan 2021 19:59:49 +0200 Subject: Add matching for query params to all the regexes --- bot/exts/info/code_snippets.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 44f11cdbd..3f943aea8 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -14,12 +14,12 @@ log = logging.getLogger(__name__) GITHUB_RE = re.compile( r'https://github\.com/(?P[a-zA-Z0-9-]+/[\w.-]+)/blob/' - r'(?P[^#>]+/{0,1})(#L(?P\d+)([-~:]L(?P\d+))?)' + r'(?P[^#>]+/{0,1})(\?[^#>]+)?(#L(?P\d+)([-~:]L(?P\d+))?)' ) GITHUB_GIST_RE = re.compile( r'https://gist\.github\.com/([a-zA-Z0-9-]+)/(?P[a-zA-Z0-9]+)/*' - r'(?P[a-zA-Z0-9]*)/*#file-(?P[^#>]+?)' + r'(?P[a-zA-Z0-9]*)/*#file-(?P[^#>]+?)(\?[^#>]+)?' r'(-L(?P\d+)([-~:]L(?P\d+))?)' ) @@ -27,7 +27,7 @@ GITHUB_HEADERS = {'Accept': 'application/vnd.github.v3.raw'} GITLAB_RE = re.compile( r'https://gitlab\.com/(?P[a-zA-Z0-9-]+?)/\-/blob/(?P[^#>]+/{0,1})' - r'(#L(?P\d+)(-(?P\d+))?)' + r'(\?[^#>]+)?(#L(?P\d+)(-(?P\d+))?)' ) BITBUCKET_RE = re.compile( -- cgit v1.2.3 From 64596679aeed67a0bfdb645ade5065af129c8c56 Mon Sep 17 00:00:00 2001 From: Andi Qu <31325319+dolphingarlic@users.noreply.github.com> Date: Sun, 24 Jan 2021 20:06:20 +0200 Subject: Match both username *and* repo in the GitLab regex --- bot/exts/info/code_snippets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 3f943aea8..e825ec513 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -26,7 +26,7 @@ GITHUB_GIST_RE = re.compile( GITHUB_HEADERS = {'Accept': 'application/vnd.github.v3.raw'} GITLAB_RE = re.compile( - r'https://gitlab\.com/(?P[a-zA-Z0-9-]+?)/\-/blob/(?P[^#>]+/{0,1})' + r'https://gitlab\.com/(?P[\w.-]+/[\w.-]+)/\-/blob/(?P[^#>]+/{0,1})' r'(\?[^#>]+)?(#L(?P\d+)(-(?P\d+))?)' ) -- cgit v1.2.3 From 4dee6d3c4e18144b35011fc4441738a82fcb522b Mon Sep 17 00:00:00 2001 From: Andi Qu <31325319+dolphingarlic@users.noreply.github.com> Date: Sat, 30 Jan 2021 11:43:14 +0200 Subject: Got rid of unnecessary regex matching things Stuff like `/{0,1}` and `?` at the ends of groups --- bot/exts/info/code_snippets.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index e825ec513..4c8de05fc 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -14,7 +14,7 @@ log = logging.getLogger(__name__) GITHUB_RE = re.compile( r'https://github\.com/(?P[a-zA-Z0-9-]+/[\w.-]+)/blob/' - r'(?P[^#>]+/{0,1})(\?[^#>]+)?(#L(?P\d+)([-~:]L(?P\d+))?)' + r'(?P[^#>]+)(\?[^#>]+)?(#L(?P\d+)([-~:]L(?P\d+))?)' ) GITHUB_GIST_RE = re.compile( @@ -26,13 +26,13 @@ GITHUB_GIST_RE = re.compile( GITHUB_HEADERS = {'Accept': 'application/vnd.github.v3.raw'} GITLAB_RE = re.compile( - r'https://gitlab\.com/(?P[\w.-]+/[\w.-]+)/\-/blob/(?P[^#>]+/{0,1})' + r'https://gitlab\.com/(?P[\w.-]+/[\w.-]+)/\-/blob/(?P[^#>]+)' r'(\?[^#>]+)?(#L(?P\d+)(-(?P\d+))?)' ) BITBUCKET_RE = re.compile( - r'https://bitbucket\.org/(?P[a-zA-Z0-9-]+/[\w.-]+?)/src/(?P[0-9a-zA-Z]+?)' - r'/(?P[^#>]+?)(\?[^#>]+)?(#lines-(?P\d+)(:(?P\d+))?)' + r'https://bitbucket\.org/(?P[a-zA-Z0-9-]+/[\w.-]+)/src/(?P[0-9a-zA-Z]+)' + r'/(?P[^#>]+)(\?[^#>]+)?(#lines-(?P\d+)(:(?P\d+))?)' ) -- cgit v1.2.3 From 25702f7d44eefbdb3d727b39bc0752e042320d8d Mon Sep 17 00:00:00 2001 From: Andi Qu Date: Sat, 30 Jan 2021 22:53:52 +0200 Subject: Use the GitLab API for GitLab snippets --- bot/exts/info/code_snippets.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 4c8de05fc..e149b5637 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -127,8 +127,11 @@ class CodeSnippets(Cog): enc_repo = quote_plus(repo) # Searches the GitLab API for the specified branch - branches = await self._fetch_response(f'https://api.github.com/repos/{repo}/branches', 'json') - tags = await self._fetch_response(f'https://api.github.com/repos/{repo}/tags', 'json') + branches = await self._fetch_response( + f'https://gitlab.com/api/v4/projects/{enc_repo}/repository/branches', + 'json' + ) + tags = await self._fetch_response(f'https://gitlab.com/api/v4/projects/{enc_repo}/repository/tags', 'json') refs = branches + tags ref, file_path = self._find_ref(path, refs) enc_ref = quote_plus(ref) -- cgit v1.2.3 From 7b27971c7d2cda0ebea091af76314f11bd6d0ba7 Mon Sep 17 00:00:00 2001 From: Andi Qu Date: Sat, 30 Jan 2021 22:56:25 +0200 Subject: Fixed syntax error with wait_for_deletion --- bot/exts/info/code_snippets.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index e149b5637..f0cd54c0c 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -234,8 +234,7 @@ class CodeSnippets(Cog): await message.edit(suppress=True) await wait_for_deletion( await message.channel.send(message_to_send), - (message.author.id,), - client=self.bot + (message.author.id,) ) -- cgit v1.2.3 From ee0cd08a113192a5e49ddb10c9ef2527d9a4d77b Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 19 Feb 2021 12:50:16 +0200 Subject: Remove verification channel special case from error handler tests We don't have a verification channel anymore, so this have no point and this just give errors. --- tests/bot/exts/backend/test_error_handler.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py index 4a0adb03e..ea21a9f58 100644 --- a/tests/bot/exts/backend/test_error_handler.py +++ b/tests/bot/exts/backend/test_error_handler.py @@ -28,22 +28,19 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): self.ctx.send.assert_not_awaited() async def test_error_handler_command_not_found_error_not_invoked_by_handler(self): - """Should try first (un)silence channel, when fail and channel is not verification channel try to get tag.""" + """Should try first (un)silence channel, when fail, try to get tag.""" error = errors.CommandNotFound() test_cases = ( { "try_silence_return": True, - "patch_verification_id": False, "called_try_get_tag": False }, { "try_silence_return": False, - "patch_verification_id": True, "called_try_get_tag": False }, { "try_silence_return": False, - "patch_verification_id": False, "called_try_get_tag": True } ) @@ -60,20 +57,15 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): cog.try_silence.return_value = case["try_silence_return"] self.ctx.channel.id = 1234 - if case["patch_verification_id"]: - with patch("bot.exts.backend.error_handler.Channels.verification", new=1234): - self.assertIsNone(await cog.on_command_error(self.ctx, error)) - else: - self.assertIsNone(await cog.on_command_error(self.ctx, error)) + self.assertIsNone(await cog.on_command_error(self.ctx, error)) + if case["try_silence_return"]: cog.try_get_tag.assert_not_awaited() cog.try_silence.assert_awaited_once() else: cog.try_silence.assert_awaited_once() - if case["patch_verification_id"]: - cog.try_get_tag.assert_not_awaited() - else: - cog.try_get_tag.assert_awaited_once() + cog.try_get_tag.assert_awaited_once() + self.ctx.send.assert_not_awaited() async def test_error_handler_command_not_found_error_invoked_by_handler(self): -- cgit v1.2.3 From d3aa3f1ca261b7b2b4f2d690d51c2479f6324bd7 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 19 Feb 2021 17:00:46 +0200 Subject: Remove unnecessary ResponseCodeError suppress --- bot/exts/backend/error_handler.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index 5351f8267..8faf46b50 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -1,4 +1,3 @@ -import contextlib import difflib import logging import random @@ -167,9 +166,8 @@ class ErrorHandler(Cog): f"and the fallback tag failed validation in TagNameConverter." ) else: - with contextlib.suppress(ResponseCodeError): - if await ctx.invoke(tags_get_command, tag_name=tag_name): - return + if await ctx.invoke(tags_get_command, tag_name=tag_name): + return if not any(role.id in MODERATION_ROLES for role in ctx.author.roles): await self.send_command_suggestion(ctx, ctx.invoked_with) -- cgit v1.2.3 From 3855b4f81e678d93db99adbd7701599b0b29748c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 19 Feb 2021 17:17:19 +0200 Subject: Update error handler tests to match with recent changes --- tests/bot/exts/backend/test_error_handler.py | 81 ++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 9 deletions(-) diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py index ea21a9f58..9b7b66cb2 100644 --- a/tests/bot/exts/backend/test_error_handler.py +++ b/tests/bot/exts/backend/test_error_handler.py @@ -4,11 +4,13 @@ from unittest.mock import AsyncMock, MagicMock, call, patch from discord.ext.commands import errors from bot.api import ResponseCodeError +from bot.errors import InvalidInfractedUser, LockedResourceError +from bot.exts.backend.branding._errors import BrandingError from bot.exts.backend.error_handler import ErrorHandler, setup from bot.exts.info.tags import Tags from bot.exts.moderation.silence import Silence from bot.utils.checks import InWhitelistCheckFailure -from tests.helpers import MockBot, MockContext, MockGuild +from tests.helpers import MockBot, MockContext, MockGuild, MockRole class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): @@ -123,20 +125,58 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): { "args": (self.ctx, errors.CommandInvokeError(TypeError)), "expect_mock_call": cog.handle_unexpected_error + }, + { + "args": (self.ctx, errors.CommandInvokeError(LockedResourceError("abc", "test"))), + "expect_mock_call": "send" + }, + { + "args": (self.ctx, errors.CommandInvokeError(BrandingError())), + "expect_mock_call": "send" + }, + { + "args": (self.ctx, errors.CommandInvokeError(InvalidInfractedUser(self.ctx.author))), + "expect_mock_call": "send" } ) for case in test_cases: with self.subTest(args=case["args"], expect_mock_call=case["expect_mock_call"]): + self.ctx.send.reset_mock() self.assertIsNone(await cog.on_command_error(*case["args"])) - case["expect_mock_call"].assert_awaited_once_with(self.ctx, case["args"][1].original) + if case["expect_mock_call"] == "send": + self.ctx.send.assert_awaited_once() + else: + case["expect_mock_call"].assert_awaited_once_with( + self.ctx, case["args"][1].original + ) - async def test_error_handler_three_other_errors(self): - """Should call `handle_unexpected_error` when `ConversionError`, `MaxConcurrencyReached` or `ExtensionError`.""" + async def test_error_handler_conversion_error(self): + """Should call `handle_api_error` or `handle_unexpected_error` depending on original error.""" + cog = ErrorHandler(self.bot) + cog.handle_api_error = AsyncMock() + cog.handle_unexpected_error = AsyncMock() + cases = ( + { + "error": errors.ConversionError(AsyncMock(), ResponseCodeError(AsyncMock())), + "mock_function_to_call": cog.handle_api_error + }, + { + "error": errors.ConversionError(AsyncMock(), TypeError), + "mock_function_to_call": cog.handle_unexpected_error + } + ) + + for case in cases: + with self.subTest(**case): + self.assertIsNone(await cog.on_command_error(self.ctx, case["error"])) + case["mock_function_to_call"].assert_awaited_once_with(self.ctx, case["error"].original) + + async def test_error_handler_two_other_errors(self): + """Should call `handle_unexpected_error` if error is `MaxConcurrencyReached` or `ExtensionError`.""" cog = ErrorHandler(self.bot) cog.handle_unexpected_error = AsyncMock() errs = ( - errors.ConversionError(MagicMock(), MagicMock()), errors.MaxConcurrencyReached(1, MagicMock()), errors.ExtensionError(name="foo") ) @@ -289,11 +329,34 @@ class TryGetTagTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.try_get_tag(self.ctx)) self.ctx.invoke.assert_awaited_once_with(self.tag.get_command, tag_name="foo") - async def test_try_get_tag_response_code_error_suppress(self): - """Should suppress `ResponseCodeError` when calling `ctx.invoke`.""" + async def test_dont_call_suggestion_tag_sent(self): + """Should never call command suggestion if tag is already sent.""" self.ctx.invoked_with = "foo" - self.ctx.invoke.side_effect = ResponseCodeError(MagicMock()) - self.assertIsNone(await self.cog.try_get_tag(self.ctx)) + self.ctx.invoke = AsyncMock(return_value=True) + self.cog.send_command_suggestion = AsyncMock() + + await self.cog.try_get_tag(self.ctx) + self.cog.send_command_suggestion.assert_not_awaited() + + @patch("bot.exts.backend.error_handler.MODERATION_ROLES", new=[1234]) + async def test_dont_call_suggestion_if_user_mod(self): + """Should not call command suggestion if user is a mod.""" + self.ctx.invoked_with = "foo" + self.ctx.invoke = AsyncMock(return_value=False) + self.ctx.author.roles = [MockRole(id=1234)] + self.cog.send_command_suggestion = AsyncMock() + + await self.cog.try_get_tag(self.ctx) + self.cog.send_command_suggestion.assert_not_awaited() + + async def test_call_suggestion(self): + """Should call command suggestion if user is not a mod.""" + self.ctx.invoked_with = "foo" + self.ctx.invoke = AsyncMock(return_value=False) + self.cog.send_command_suggestion = AsyncMock() + + await self.cog.try_get_tag(self.ctx) + self.cog.send_command_suggestion.assert_awaited_once_with(self.ctx, "foo") class IndividualErrorHandlerTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From 0dd2e75be2368a4197e9370cf982dc8be8fa862b Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sat, 20 Feb 2021 17:01:19 +0100 Subject: Add bot and verified bot badges to the user embed. --- bot/constants.py | 2 ++ bot/exts/info/information.py | 3 +++ config-default.yml | 2 ++ 3 files changed, 7 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 8a93ff9cf..91d425b1d 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -279,6 +279,8 @@ class Emojis(metaclass=YAMLGetter): badge_partner: str badge_staff: str badge_verified_bot_developer: str + badge_verified_bot: str + bot: str defcon_disabled: str # noqa: E704 defcon_enabled: str # noqa: E704 diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 4499e4c25..256be2161 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -228,6 +228,9 @@ class Information(Cog): if on_server and user.nick: name = f"{user.nick} ({name})" + if user.bot: + name += f" {constants.Emojis.bot}" + badges = [] for badge, is_set in user.public_flags: diff --git a/config-default.yml b/config-default.yml index 25bbcc3c5..822b37daf 100644 --- a/config-default.yml +++ b/config-default.yml @@ -46,6 +46,8 @@ style: badge_partner: "<:partner:748666453242413136>" badge_staff: "<:discord_staff:743882896498098226>" badge_verified_bot_developer: "<:verified_bot_dev:743882897299210310>" + badge_verified_bot: "<:verified_bot:811645219220750347>" + bot: "<:bot:812712599464443914>" defcon_disabled: "<:defcondisabled:470326273952972810>" defcon_enabled: "<:defconenabled:470326274213150730>" -- cgit v1.2.3 From fcf0a82296ddf1c46623b14ca0bfca6d50514242 Mon Sep 17 00:00:00 2001 From: asleep-cult Date: Mon, 1 Mar 2021 09:51:49 -0500 Subject: Unescape html escape characters in reddit text and titles --- bot/exts/info/reddit.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/reddit.py b/bot/exts/info/reddit.py index 6790be762..3630a02ee 100644 --- a/bot/exts/info/reddit.py +++ b/bot/exts/info/reddit.py @@ -2,6 +2,7 @@ import asyncio import logging import random import textwrap +import html from collections import namedtuple from datetime import datetime, timedelta from typing import List @@ -179,7 +180,7 @@ class Reddit(Cog): for post in posts: data = post["data"] - text = data["selftext"] + text = html.unescape(data["selftext"]) if text: text = textwrap.shorten(text, width=128, placeholder="...") text += "\n" # Add newline to separate embed info @@ -188,7 +189,8 @@ class Reddit(Cog): comments = data["num_comments"] author = data["author"] - title = textwrap.shorten(data["title"], width=64, placeholder="...") + title = html.unescape(data["title"]) + title = textwrap.shorten(title, width=64, placeholder="...") # Normal brackets interfere with Markdown. title = escape_markdown(title).replace("[", "⦋").replace("]", "⦌") link = self.URL + data["permalink"] -- cgit v1.2.3 From c04d83721baf68f6beb6bd0d830f7602916f21ed Mon Sep 17 00:00:00 2001 From: asleep-cult Date: Mon, 1 Mar 2021 10:09:03 -0500 Subject: Make flake8 happy --- bot/exts/info/reddit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/reddit.py b/bot/exts/info/reddit.py index 3630a02ee..2a74de1d4 100644 --- a/bot/exts/info/reddit.py +++ b/bot/exts/info/reddit.py @@ -1,8 +1,8 @@ import asyncio import logging import random -import textwrap import html +import textwrap from collections import namedtuple from datetime import datetime, timedelta from typing import List -- cgit v1.2.3 From 6e3ff04da525b0987464b6012b9bb6747bf6fce3 Mon Sep 17 00:00:00 2001 From: asleep-cult Date: Mon, 1 Mar 2021 10:55:46 -0500 Subject: Fix pre-commit issues --- bot/exts/info/reddit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/reddit.py b/bot/exts/info/reddit.py index 2a74de1d4..2711fd43d 100644 --- a/bot/exts/info/reddit.py +++ b/bot/exts/info/reddit.py @@ -1,7 +1,7 @@ import asyncio import logging -import random import html +import random import textwrap from collections import namedtuple from datetime import datetime, timedelta -- cgit v1.2.3 From e48ff8e25afcf22e7476fc92e074cc0f00f48695 Mon Sep 17 00:00:00 2001 From: asleep-cult Date: Mon, 1 Mar 2021 11:43:37 -0500 Subject: Fix import order --- bot/exts/info/reddit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/reddit.py b/bot/exts/info/reddit.py index 2711fd43d..534917c07 100644 --- a/bot/exts/info/reddit.py +++ b/bot/exts/info/reddit.py @@ -1,6 +1,6 @@ import asyncio -import logging import html +import logging import random import textwrap from collections import namedtuple -- cgit v1.2.3 From 2b6d58db377a22b049a67738a6cfba50e771b628 Mon Sep 17 00:00:00 2001 From: asleep-cult Date: Mon, 1 Mar 2021 12:00:07 -0500 Subject: Import unescape directly --- bot/exts/info/reddit.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/reddit.py b/bot/exts/info/reddit.py index 534917c07..9c026caca 100644 --- a/bot/exts/info/reddit.py +++ b/bot/exts/info/reddit.py @@ -1,10 +1,10 @@ import asyncio -import html import logging import random import textwrap from collections import namedtuple from datetime import datetime, timedelta +from html import unescape from typing import List from aiohttp import BasicAuth, ClientError @@ -180,7 +180,7 @@ class Reddit(Cog): for post in posts: data = post["data"] - text = html.unescape(data["selftext"]) + text = unescape(data["selftext"]) if text: text = textwrap.shorten(text, width=128, placeholder="...") text += "\n" # Add newline to separate embed info @@ -189,7 +189,7 @@ class Reddit(Cog): comments = data["num_comments"] author = data["author"] - title = html.unescape(data["title"]) + title = unescape(data["title"]) title = textwrap.shorten(title, width=64, placeholder="...") # Normal brackets interfere with Markdown. title = escape_markdown(title).replace("[", "⦋").replace("]", "⦌") -- cgit v1.2.3 From 7a07fa89746e70f1539ae57912ed19e5690a561a Mon Sep 17 00:00:00 2001 From: SavagePastaMan <69145546+SavagePastaMan@users.noreply.github.com> Date: Mon, 12 Apr 2021 10:09:43 -0400 Subject: Create identity.md Tag to demonstrate the difference between `is` and `==`. --- bot/resources/tags/identity.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 bot/resources/tags/identity.md diff --git a/bot/resources/tags/identity.md b/bot/resources/tags/identity.md new file mode 100644 index 000000000..32995aef6 --- /dev/null +++ b/bot/resources/tags/identity.md @@ -0,0 +1,25 @@ +**Identity vs. Equality** + +Should I be using `is` or `==`? + +To check if two things are equal, use the equality operator (`==`). +```py +x = 5 +if x == 5: + print("x equals 5") +if x == 3: + print("x equals 3") +# Prints 'x equals 5' +``` + +To check if two things are actually the same thing in memory, use the identity comparison operator (`is`). +```py +x = [1, 2, 3] +y = [1, 2, 3] +if x is [1, 2, 3]: + print("x is y") +z = x +if x is z: + print("x is z") +# Prints 'x is z' +``` -- cgit v1.2.3 From d0f6c92df65d551b12cc914f15bd20994729c7ce Mon Sep 17 00:00:00 2001 From: SavagePastaMan <69145546+SavagePastaMan@users.noreply.github.com> Date: Mon, 12 Apr 2021 11:20:43 -0400 Subject: Create str-join.md Create a tag to showcase `str.join` --- bot/resources/tags/str-join.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 bot/resources/tags/str-join.md diff --git a/bot/resources/tags/str-join.md b/bot/resources/tags/str-join.md new file mode 100644 index 000000000..e8407ac26 --- /dev/null +++ b/bot/resources/tags/str-join.md @@ -0,0 +1,25 @@ +**Joining Iterables** + +Suppose you want to nicely display a list (or some other iterable). The naive solution would be something like this. +```py +colors = ['red', 'green', 'blue', 'yellow'] +output = "" +separator = ", " +for color in colors: + output += color + separator +print(output) # Prints 'red, green, blue, yellow, ' +``` +However, this solution is flawed. The separator is still added to the last color, and it is slow. + +The better way is to use `str.join`. +```py +colors = ['red', 'green', 'blue', 'yellow'] +separator = ", " +print(separator.join(colors)) # Prints 'red, green, blue, yellow' +``` +This method is much simpler, faster, and solves the problem of the extra separator. An important thing to note is that you can only `str.join` strings. For a list of ints, +you must convert each element to a string before joining. +```py +integers = [1, 3, 6, 10, 15] +print(", ".join(str(e) for e in integers)) # Prints '1, 3, 6, 10, 15' +``` -- cgit v1.2.3 From 8baee2e1f04df52a87f620a8c004028cecdc9e39 Mon Sep 17 00:00:00 2001 From: SavagePastaMan <69145546+SavagePastaMan@users.noreply.github.com> Date: Mon, 12 Apr 2021 18:00:23 -0400 Subject: Be more consistent with word choice. Changed "method" and "way" to "solution" --- bot/resources/tags/str-join.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/resources/tags/str-join.md b/bot/resources/tags/str-join.md index e8407ac26..6390db9e5 100644 --- a/bot/resources/tags/str-join.md +++ b/bot/resources/tags/str-join.md @@ -9,15 +9,15 @@ for color in colors: output += color + separator print(output) # Prints 'red, green, blue, yellow, ' ``` -However, this solution is flawed. The separator is still added to the last color, and it is slow. +However, this solution is flawed. The separator is still added to the last element, and it is slow. -The better way is to use `str.join`. +The better solution is to use `str.join`. ```py colors = ['red', 'green', 'blue', 'yellow'] separator = ", " print(separator.join(colors)) # Prints 'red, green, blue, yellow' ``` -This method is much simpler, faster, and solves the problem of the extra separator. An important thing to note is that you can only `str.join` strings. For a list of ints, +This solution is much simpler, faster, and solves the problem of the extra separator. An important thing to note is that you can only `str.join` strings. For a list of ints, you must convert each element to a string before joining. ```py integers = [1, 3, 6, 10, 15] -- cgit v1.2.3 From 1bda27d3d93f9361baec25df37b39b2d2dd1c4b9 Mon Sep 17 00:00:00 2001 From: SavagePastaMan <69145546+SavagePastaMan@users.noreply.github.com> Date: Mon, 12 Apr 2021 18:40:53 -0400 Subject: Move comments to their own line. Previously the inline comments would wrap onto their own line which looked terrible. --- bot/resources/tags/str-join.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/bot/resources/tags/str-join.md b/bot/resources/tags/str-join.md index 6390db9e5..a6b8fb793 100644 --- a/bot/resources/tags/str-join.md +++ b/bot/resources/tags/str-join.md @@ -7,7 +7,8 @@ output = "" separator = ", " for color in colors: output += color + separator -print(output) # Prints 'red, green, blue, yellow, ' +print(output) +# Prints 'red, green, blue, yellow, ' ``` However, this solution is flawed. The separator is still added to the last element, and it is slow. @@ -15,11 +16,13 @@ The better solution is to use `str.join`. ```py colors = ['red', 'green', 'blue', 'yellow'] separator = ", " -print(separator.join(colors)) # Prints 'red, green, blue, yellow' +print(separator.join(colors)) +# Prints 'red, green, blue, yellow' ``` This solution is much simpler, faster, and solves the problem of the extra separator. An important thing to note is that you can only `str.join` strings. For a list of ints, you must convert each element to a string before joining. ```py integers = [1, 3, 6, 10, 15] -print(", ".join(str(e) for e in integers)) # Prints '1, 3, 6, 10, 15' +print(", ".join(str(e) for e in integers)) +# Prints '1, 3, 6, 10, 15' ``` -- cgit v1.2.3 From e06f496a6e3f9a9d6cfaeb3902547aa9da1dd7c1 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Wed, 14 Apr 2021 20:05:04 +0300 Subject: Add Duty cog and new Moderators role Added a cog to allow moderators to go off and on duty. The off-duty state is cached via a redis cache, and its expiry is scheduled via the Scheduler. Additionally changes which roles are pinged on mod alerts. --- bot/constants.py | 1 + bot/exts/moderation/duty.py | 135 ++++++++++++++++++++++++++++++++++++++++++ bot/exts/moderation/modlog.py | 6 +- config-default.yml | 5 +- 4 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 bot/exts/moderation/duty.py diff --git a/bot/constants.py b/bot/constants.py index 6d14bbb3a..cc3aa41a5 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -491,6 +491,7 @@ class Roles(metaclass=YAMLGetter): domain_leads: int helpers: int moderators: int + mod_team: int owners: int project_leads: int diff --git a/bot/exts/moderation/duty.py b/bot/exts/moderation/duty.py new file mode 100644 index 000000000..13be016f2 --- /dev/null +++ b/bot/exts/moderation/duty.py @@ -0,0 +1,135 @@ +import datetime +import logging + +from async_rediscache import RedisCache +from dateutil.parser import isoparse +from discord import Member +from discord.ext.commands import Cog, Context, group, has_any_role + +from bot.bot import Bot +from bot.constants import Emojis, Guild, MODERATION_ROLES, Roles +from bot.converters import Expiry +from bot.utils.scheduling import Scheduler + + +log = logging.getLogger(__name__) + + +class Duty(Cog): + """Commands for a moderator to go on and off duty.""" + + # RedisCache[str, str] + # The cache's keys are mods who are off-duty. + # The cache's values are the times when the role should be re-applied to them, stored in ISO format. + off_duty_mods = RedisCache() + + def __init__(self, bot: Bot): + self.bot = bot + self._role_scheduler = Scheduler(self.__class__.__name__) + + self.guild = None + self.moderators_role = None + + self.bot.loop.create_task(self.reschedule_roles()) + + async def reschedule_roles(self) -> None: + """Reschedule moderators role re-apply times.""" + await self.bot.wait_until_guild_available() + self.guild = self.bot.get_guild(Guild.id) + self.moderators_role = self.guild.get_role(Roles.moderators) + + mod_team = self.guild.get_role(Roles.mod_team) + on_duty = self.moderators_role.members + off_duty = await self.off_duty_mods.to_dict() + + log.trace("Applying the moderators role to the mod team where necessary.") + for mod in mod_team.members: + if mod in on_duty: # Make sure that on-duty mods aren't in the cache. + if mod in off_duty: + await self.off_duty_mods.delete(mod.id) + continue + + # Keep the role off only for those in the cache. + if mod.id not in off_duty: + await self.reapply_role(mod) + else: + expiry = isoparse(off_duty[mod.id]).replace(tzinfo=None) + self._role_scheduler.schedule_at(expiry, mod.id, self.reapply_role(mod)) + + async def reapply_role(self, mod: Member) -> None: + """Reapply the moderator's role to the given moderator.""" + log.trace(f"Re-applying role to mod with ID {mod.id}.") + await mod.add_roles(self.moderators_role, reason="Off-duty period expired.") + + @group(name='duty', invoke_without_command=True) + @has_any_role(*MODERATION_ROLES) + async def duty_group(self, ctx: Context) -> None: + """Allow the removal and re-addition of the pingable moderators role.""" + await ctx.send_help(ctx.command) + + @duty_group.command(name='off') + @has_any_role(*MODERATION_ROLES) + async def off_command(self, ctx: Context, duration: Expiry) -> None: + """ + Temporarily removes the pingable moderators role for a set amount of time. + + A unit of time should be appended to the duration. + Units (∗case-sensitive): + \u2003`y` - years + \u2003`m` - months∗ + \u2003`w` - weeks + \u2003`d` - days + \u2003`h` - hours + \u2003`M` - minutes∗ + \u2003`s` - seconds + + Alternatively, an ISO 8601 timestamp can be provided for the duration. + + The duration cannot be longer than 30 days. + """ + duration: datetime.datetime + delta = duration - datetime.datetime.utcnow() + if delta > datetime.timedelta(days=30): + await ctx.send(":x: Cannot remove the role for longer than 30 days.") + return + + mod = ctx.author + + await mod.remove_roles(self.moderators_role, reason="Entered off-duty period.") + + await self.off_duty_mods.update({mod.id: duration.isoformat()}) + + if mod.id in self._role_scheduler: + self._role_scheduler.cancel(mod.id) + self._role_scheduler.schedule_at(duration, mod.id, self.reapply_role(mod)) + + until_date = duration.replace(microsecond=0).isoformat() + await ctx.send(f"{Emojis.check_mark} Moderators role has been removed until {until_date}.") + + @duty_group.command(name='on') + @has_any_role(*MODERATION_ROLES) + async def on_command(self, ctx: Context) -> None: + """Re-apply the pingable moderators role.""" + mod = ctx.author + if mod in self.moderators_role.members: + await ctx.send(":question: You already have the role.") + return + + await mod.add_roles(self.moderators_role, reason="Off-duty period canceled.") + + await self.off_duty_mods.delete(mod.id) + + if mod.id in self._role_scheduler: + self._role_scheduler.cancel(mod.id) + + await ctx.send(f"{Emojis.check_mark} Moderators role has been re-applied.") + + def cog_unload(self) -> None: + """Cancel role tasks when the cog unloads.""" + log.trace("Cog unload: canceling role tasks.") + self._role_scheduler.cancel_all() + + +def setup(bot: Bot) -> None: + """Load the Slowmode cog.""" + bot.add_cog(Duty(bot)) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index 2dae9d268..f68a1880e 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -14,7 +14,7 @@ from discord.abc import GuildChannel from discord.ext.commands import Cog, Context from bot.bot import Bot -from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs +from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, Roles, URLs from bot.utils.messages import format_user from bot.utils.time import humanize_delta @@ -115,9 +115,9 @@ class ModLog(Cog, name="ModLog"): if ping_everyone: if content: - content = f"@everyone\n{content}" + content = f"<@&{Roles.moderators}> @here\n{content}" else: - content = "@everyone" + content = f"<@&{Roles.moderators}> @here" # Truncate content to 2000 characters and append an ellipsis. if content and len(content) > 2000: diff --git a/config-default.yml b/config-default.yml index 8c6e18470..6eb954cd5 100644 --- a/config-default.yml +++ b/config-default.yml @@ -260,7 +260,8 @@ guild: devops: 409416496733880320 domain_leads: 807415650778742785 helpers: &HELPERS_ROLE 267630620367257601 - moderators: &MODS_ROLE 267629731250176001 + moderators: &MODS_ROLE 831776746206265384 + mod_team: &MOD_TEAM_ROLE 267629731250176001 owners: &OWNERS_ROLE 267627879762755584 project_leads: 815701647526330398 @@ -274,12 +275,14 @@ guild: moderation_roles: - *ADMINS_ROLE - *MODS_ROLE + - *MOD_TEAM_ROLE - *OWNERS_ROLE staff_roles: - *ADMINS_ROLE - *HELPERS_ROLE - *MODS_ROLE + - *MOD_TEAM_ROLE - *OWNERS_ROLE webhooks: -- cgit v1.2.3 From 65df8e24874cda7b9525acde346199f66e59650f Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Thu, 15 Apr 2021 00:55:29 +0300 Subject: Remove extra newline Co-authored-by: ks129 <45097959+ks129@users.noreply.github.com> --- bot/exts/moderation/duty.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/moderation/duty.py b/bot/exts/moderation/duty.py index 13be016f2..94eed9331 100644 --- a/bot/exts/moderation/duty.py +++ b/bot/exts/moderation/duty.py @@ -11,7 +11,6 @@ from bot.constants import Emojis, Guild, MODERATION_ROLES, Roles from bot.converters import Expiry from bot.utils.scheduling import Scheduler - log = logging.getLogger(__name__) -- cgit v1.2.3 From 38714aef8c5b71c5e8313a82bef18947f1f1395a Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 15 Apr 2021 01:00:31 +0300 Subject: Fix setup docstring to specify correct cog --- bot/exts/moderation/duty.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/duty.py b/bot/exts/moderation/duty.py index 94eed9331..3f34e366c 100644 --- a/bot/exts/moderation/duty.py +++ b/bot/exts/moderation/duty.py @@ -130,5 +130,5 @@ class Duty(Cog): def setup(bot: Bot) -> None: - """Load the Slowmode cog.""" + """Load the Duty cog.""" bot.add_cog(Duty(bot)) -- cgit v1.2.3 From 6c00f74c8dcd2f3f1aaa4eff89e72cc135b75357 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 15 Apr 2021 01:04:16 +0300 Subject: Add off-duty expiration date to audit log --- bot/exts/moderation/duty.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/duty.py b/bot/exts/moderation/duty.py index 3f34e366c..265261be8 100644 --- a/bot/exts/moderation/duty.py +++ b/bot/exts/moderation/duty.py @@ -94,7 +94,8 @@ class Duty(Cog): mod = ctx.author - await mod.remove_roles(self.moderators_role, reason="Entered off-duty period.") + until_date = duration.replace(microsecond=0).isoformat() + await mod.remove_roles(self.moderators_role, reason=f"Entered off-duty period until {until_date}.") await self.off_duty_mods.update({mod.id: duration.isoformat()}) @@ -102,7 +103,6 @@ class Duty(Cog): self._role_scheduler.cancel(mod.id) self._role_scheduler.schedule_at(duration, mod.id, self.reapply_role(mod)) - until_date = duration.replace(microsecond=0).isoformat() await ctx.send(f"{Emojis.check_mark} Moderators role has been removed until {until_date}.") @duty_group.command(name='on') -- cgit v1.2.3 From b5fbca6f32c437aa45e28916451de39fb1485a75 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 15 Apr 2021 01:10:37 +0300 Subject: Use set instead of update in duty off --- bot/exts/moderation/duty.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/duty.py b/bot/exts/moderation/duty.py index 265261be8..0b07510db 100644 --- a/bot/exts/moderation/duty.py +++ b/bot/exts/moderation/duty.py @@ -97,7 +97,7 @@ class Duty(Cog): until_date = duration.replace(microsecond=0).isoformat() await mod.remove_roles(self.moderators_role, reason=f"Entered off-duty period until {until_date}.") - await self.off_duty_mods.update({mod.id: duration.isoformat()}) + await self.off_duty_mods.set(mod.id, duration.isoformat()) if mod.id in self._role_scheduler: self._role_scheduler.cancel(mod.id) -- cgit v1.2.3 From 1f2a04870c509b5667d903c187fe05e0796be041 Mon Sep 17 00:00:00 2001 From: asleep-cult Date: Thu, 15 Apr 2021 11:19:43 -0400 Subject: Resolved issues --- bot/exts/info/reddit.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/exts/info/reddit.py b/bot/exts/info/reddit.py index 9c026caca..e1f0c5f9f 100644 --- a/bot/exts/info/reddit.py +++ b/bot/exts/info/reddit.py @@ -180,8 +180,7 @@ class Reddit(Cog): for post in posts: data = post["data"] - text = unescape(data["selftext"]) - if text: + if text := unescape(data["selftext"]): text = textwrap.shorten(text, width=128, placeholder="...") text += "\n" # Add newline to separate embed info @@ -189,8 +188,7 @@ class Reddit(Cog): comments = data["num_comments"] author = data["author"] - title = unescape(data["title"]) - title = textwrap.shorten(title, width=64, placeholder="...") + title = textwrap.shorten(unescape(data["title"]), width=64, placeholder="...") # Normal brackets interfere with Markdown. title = escape_markdown(title).replace("[", "⦋").replace("]", "⦌") link = self.URL + data["permalink"] -- cgit v1.2.3 From f80303718eed9bc676fe2e3e3fc06cffffbf1a92 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 15 Apr 2021 22:10:26 +0200 Subject: Make trace logging optional and allow selective enabling Because coloredlogs' install changes the level of the root handler, the setLevel call had to be moved to after the install. --- bot/constants.py | 1 + bot/log.py | 20 ++++++++++++++------ config-default.yml | 7 ++++--- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 6d14bbb3a..14400700f 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -199,6 +199,7 @@ class Bot(metaclass=YAMLGetter): prefix: str sentry_dsn: Optional[str] token: str + trace_loggers: Optional[str] class Redis(metaclass=YAMLGetter): diff --git a/bot/log.py b/bot/log.py index e92233a33..339ed63a7 100644 --- a/bot/log.py +++ b/bot/log.py @@ -20,7 +20,6 @@ def setup() -> None: logging.addLevelName(TRACE_LEVEL, "TRACE") Logger.trace = _monkeypatch_trace - log_level = TRACE_LEVEL if constants.DEBUG_MODE else logging.INFO format_string = "%(asctime)s | %(name)s | %(levelname)s | %(message)s" log_format = logging.Formatter(format_string) @@ -30,7 +29,6 @@ def setup() -> None: file_handler.setFormatter(log_format) root_log = logging.getLogger() - root_log.setLevel(log_level) root_log.addHandler(file_handler) if "COLOREDLOGS_LEVEL_STYLES" not in os.environ: @@ -44,11 +42,9 @@ def setup() -> None: if "COLOREDLOGS_LOG_FORMAT" not in os.environ: coloredlogs.DEFAULT_LOG_FORMAT = format_string - if "COLOREDLOGS_LOG_LEVEL" not in os.environ: - coloredlogs.DEFAULT_LOG_LEVEL = log_level - - coloredlogs.install(logger=root_log, stream=sys.stdout) + coloredlogs.install(level=logging.TRACE, logger=root_log, stream=sys.stdout) + root_log.setLevel(logging.DEBUG if constants.DEBUG_MODE else logging.INFO) logging.getLogger("discord").setLevel(logging.WARNING) logging.getLogger("websockets").setLevel(logging.WARNING) logging.getLogger("chardet").setLevel(logging.WARNING) @@ -57,6 +53,8 @@ def setup() -> None: # Set back to the default of INFO even if asyncio's debug mode is enabled. logging.getLogger("asyncio").setLevel(logging.INFO) + _set_trace_loggers() + def setup_sentry() -> None: """Set up the Sentry logging integrations.""" @@ -86,3 +84,13 @@ def _monkeypatch_trace(self: logging.Logger, msg: str, *args, **kwargs) -> None: """ if self.isEnabledFor(TRACE_LEVEL): self._log(TRACE_LEVEL, msg, args, **kwargs) + + +def _set_trace_loggers() -> None: + """Set loggers to the trace level according to the value from the BOT_TRACE_LOGGERS env var.""" + if constants.Bot.trace_loggers: + if constants.Bot.trace_loggers in {"*", "ROOT"}: + logging.getLogger().setLevel(logging.TRACE) + else: + for logger_name in constants.Bot.trace_loggers.split(","): + logging.getLogger(logger_name).setLevel(logging.TRACE) diff --git a/config-default.yml b/config-default.yml index 8c6e18470..b9786925d 100644 --- a/config-default.yml +++ b/config-default.yml @@ -1,7 +1,8 @@ bot: - prefix: "!" - sentry_dsn: !ENV "BOT_SENTRY_DSN" - token: !ENV "BOT_TOKEN" + prefix: "!" + sentry_dsn: !ENV "BOT_SENTRY_DSN" + token: !ENV "BOT_TOKEN" + trace_loggers: !ENV "BOT_TRACE_LOGGERS" clean: # Maximum number of messages to traverse for clean commands -- cgit v1.2.3 From f11ebfde17634eed7fa242f72b309c4a75c885cd Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 17 Apr 2021 01:15:38 +0300 Subject: Keep config succint A moderator is expected to have the mod-team role and therefore it's enough to specify the latter in the mod and staff roles. --- config-default.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/config-default.yml b/config-default.yml index 6eb954cd5..b19164d3f 100644 --- a/config-default.yml +++ b/config-default.yml @@ -274,14 +274,12 @@ guild: moderation_roles: - *ADMINS_ROLE - - *MODS_ROLE - *MOD_TEAM_ROLE - *OWNERS_ROLE staff_roles: - *ADMINS_ROLE - *HELPERS_ROLE - - *MODS_ROLE - *MOD_TEAM_ROLE - *OWNERS_ROLE -- cgit v1.2.3 From 2053b2e36ece02680ed85b970c4fbf687fe07e0f Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 17 Apr 2021 01:19:54 +0300 Subject: Assume a scheduled task exists for `duty on` The lack of such a task may be indicative of a bug. --- bot/exts/moderation/duty.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/moderation/duty.py b/bot/exts/moderation/duty.py index 0b07510db..8d0c96363 100644 --- a/bot/exts/moderation/duty.py +++ b/bot/exts/moderation/duty.py @@ -118,8 +118,7 @@ class Duty(Cog): await self.off_duty_mods.delete(mod.id) - if mod.id in self._role_scheduler: - self._role_scheduler.cancel(mod.id) + self._role_scheduler.cancel(mod.id) await ctx.send(f"{Emojis.check_mark} Moderators role has been re-applied.") -- cgit v1.2.3 From 5506fb74f90831e686f4636595f62e4bcc72a703 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 17 Apr 2021 01:24:17 +0300 Subject: Improve documentation --- bot/exts/moderation/duty.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/duty.py b/bot/exts/moderation/duty.py index 8d0c96363..eab0fd99f 100644 --- a/bot/exts/moderation/duty.py +++ b/bot/exts/moderation/duty.py @@ -94,11 +94,12 @@ class Duty(Cog): mod = ctx.author - until_date = duration.replace(microsecond=0).isoformat() + until_date = duration.replace(microsecond=0).isoformat() # Looks noisy with microseconds. await mod.remove_roles(self.moderators_role, reason=f"Entered off-duty period until {until_date}.") await self.off_duty_mods.set(mod.id, duration.isoformat()) + # Allow rescheduling the task without cancelling it separately via the `on` command. if mod.id in self._role_scheduler: self._role_scheduler.cancel(mod.id) self._role_scheduler.schedule_at(duration, mod.id, self.reapply_role(mod)) @@ -118,6 +119,7 @@ class Duty(Cog): await self.off_duty_mods.delete(mod.id) + # We assume the task exists. Lack of it may indicate a bug. self._role_scheduler.cancel(mod.id) await ctx.send(f"{Emojis.check_mark} Moderators role has been re-applied.") -- cgit v1.2.3 From 4a051cdb016748daca724e95957bd011cc3f6c3f Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 17 Apr 2021 01:43:17 +0300 Subject: Name the rescheduling task, and cancel it on cog unload --- bot/exts/moderation/duty.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/duty.py b/bot/exts/moderation/duty.py index eab0fd99f..e05472448 100644 --- a/bot/exts/moderation/duty.py +++ b/bot/exts/moderation/duty.py @@ -29,7 +29,7 @@ class Duty(Cog): self.guild = None self.moderators_role = None - self.bot.loop.create_task(self.reschedule_roles()) + self.reschedule_task = self.bot.loop.create_task(self.reschedule_roles(), name="duty-reschedule") async def reschedule_roles(self) -> None: """Reschedule moderators role re-apply times.""" @@ -127,6 +127,7 @@ class Duty(Cog): def cog_unload(self) -> None: """Cancel role tasks when the cog unloads.""" log.trace("Cog unload: canceling role tasks.") + self.reschedule_task.cancel() self._role_scheduler.cancel_all() -- cgit v1.2.3 From d2d939c96de22ae174072dd8cc2bad2fe4f2174a Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Sat, 17 Apr 2021 13:19:08 +0300 Subject: Remove here ping Kinda defeats the purpose of being off-duty. --- bot/exts/moderation/modlog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index f68a1880e..5e8ea595b 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -115,9 +115,9 @@ class ModLog(Cog, name="ModLog"): if ping_everyone: if content: - content = f"<@&{Roles.moderators}> @here\n{content}" + content = f"<@&{Roles.moderators}>\n{content}" else: - content = f"<@&{Roles.moderators}> @here" + content = f"<@&{Roles.moderators}>" # Truncate content to 2000 characters and append an ellipsis. if content and len(content) > 2000: -- cgit v1.2.3 From 0e4fd3d2d0ae4b0f403cc8f163c783284aefae56 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sat, 17 Apr 2021 18:37:44 +0200 Subject: Make YAMLGetter raise AttributeError instead of KeyError Utility functions such as hasattr or getattr except __getattribute__ to raise AttributeError not KeyError. This commit also lowers the logging level of the error message to info since it is up to the caller to decide if this is an expected failure or not. --- bot/constants.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index dc9cd4dfb..3254c2761 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -175,13 +175,14 @@ class YAMLGetter(type): if cls.subsection is not None: return _CONFIG_YAML[cls.section][cls.subsection][name] return _CONFIG_YAML[cls.section][name] - except KeyError: + except KeyError as e: dotted_path = '.'.join( (cls.section, cls.subsection, name) if cls.subsection is not None else (cls.section, name) ) - log.critical(f"Tried accessing configuration variable at `{dotted_path}`, but it could not be found.") - raise + # Only an INFO log since this can be caught through `hasattr` or `getattr`. + log.info(f"Tried accessing configuration variable at `{dotted_path}`, but it could not be found.") + raise AttributeError(repr(name)) from e def __getitem__(cls, name): return cls.__getattr__(name) -- cgit v1.2.3 From c910427937760f50fe7df3851989170c3494cde2 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sat, 17 Apr 2021 18:38:09 +0200 Subject: Move the verified developer badge to the embed title --- bot/constants.py | 2 +- bot/exts/info/information.py | 4 +++- config-default.yml | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 3254c2761..813f970cd 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -280,7 +280,7 @@ class Emojis(metaclass=YAMLGetter): badge_partner: str badge_staff: str badge_verified_bot_developer: str - badge_verified_bot: str + verified_bot: str bot: str defcon_shutdown: str # noqa: E704 diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 226e4992e..834fee1b4 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -230,7 +230,9 @@ class Information(Cog): if on_server and user.nick: name = f"{user.nick} ({name})" - if user.bot: + if user.public_flags.verified_bot: + name += f" {constants.Emojis.verified_bot}" + elif user.bot: name += f" {constants.Emojis.bot}" badges = [] diff --git a/config-default.yml b/config-default.yml index dba354117..b6955c63c 100644 --- a/config-default.yml +++ b/config-default.yml @@ -46,8 +46,8 @@ style: badge_partner: "<:partner:748666453242413136>" badge_staff: "<:discord_staff:743882896498098226>" badge_verified_bot_developer: "<:verified_bot_dev:743882897299210310>" - badge_verified_bot: "<:verified_bot:811645219220750347>" bot: "<:bot:812712599464443914>" + verified_bot: "<:verified_bot:811645219220750347>" defcon_shutdown: "<:defcondisabled:470326273952972810>" defcon_unshutdown: "<:defconenabled:470326274213150730>" -- cgit v1.2.3 From 93c9e536a3e771db2ac03054a5c2470883d59f1f Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sat, 17 Apr 2021 18:52:19 +0200 Subject: Tests: members shouldn't have any public flags --- tests/bot/exts/info/test_information.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index a996ce477..d2ecee033 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -281,9 +281,13 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): """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)) user = helpers.MockMember() + public_flags = unittest.mock.MagicMock() + public_flags.__iter__.return_value = iter(()) + public_flags.verified_bot = False user.nick = None user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") user.colour = 0 + user.public_flags = public_flags embed = await self.cog.create_user_embed(ctx, user) @@ -297,9 +301,13 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): """The embed should use the nick if it's available.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) user = helpers.MockMember() + public_flags = unittest.mock.MagicMock() + public_flags.__iter__.return_value = iter(()) + public_flags.verified_bot = False user.nick = "Cat lover" user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") user.colour = 0 + user.public_flags = public_flags embed = await self.cog.create_user_embed(ctx, user) -- cgit v1.2.3 From 17770021be89e82c0e3edf1d01a6e10775fd871a Mon Sep 17 00:00:00 2001 From: Andi Qu <31325319+dolphingarlic@users.noreply.github.com> Date: Sat, 17 Apr 2021 19:02:20 +0200 Subject: Sort snippet matches by their start position --- bot/exts/info/code_snippets.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index f0cd54c0c..b9e7cc3d0 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -205,9 +205,9 @@ class CodeSnippets(Cog): ret = f'`{file_path}` lines {start_line} to {end_line}\n' if len(required) != 0: - return f'{ret}```{language}\n{required}```\n' + return f'{ret}```{language}\n{required}```' # Returns an empty codeblock if the snippet is empty - return f'{ret}``` ```\n' + return f'{ret}``` ```' def __init__(self, bot: Bot): """Initializes the cog's bot.""" @@ -224,13 +224,18 @@ class CodeSnippets(Cog): async def on_message(self, message: Message) -> None: """Checks if the message has a snippet link, removes the embed, then sends the snippet contents.""" if not message.author.bot: - message_to_send = '' + all_snippets = [] for pattern, handler in self.pattern_handlers: for match in pattern.finditer(message.content): - message_to_send += await handler(**match.groupdict()) + snippet = await handler(**match.groupdict()) + all_snippets.append((match.start(), snippet)) - if 0 < len(message_to_send) <= 2000 and message_to_send.count('\n') <= 15: + # Sorts the list of snippets by their match index and joins them into + # a single message + message_to_send = '\n'.join(map(lambda x: x[1], sorted(all_snippets))) + + if 0 < len(message_to_send) <= 2000 and len(all_snippets) <= 15: await message.edit(suppress=True) await wait_for_deletion( await message.channel.send(message_to_send), -- cgit v1.2.3 From 94af3c07678f1f2dee722f4780a816426efd0851 Mon Sep 17 00:00:00 2001 From: Vivaan Verma Date: Sun, 18 Apr 2021 21:12:08 +0100 Subject: Added default duration of 1h to superstarify --- bot/exts/moderation/infraction/superstarify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 704dddf9c..245f14905 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -109,7 +109,7 @@ class Superstarify(InfractionScheduler, Cog): self, ctx: Context, member: Member, - duration: Expiry, + duration: Expiry = "1h", *, reason: str = '', ) -> None: -- cgit v1.2.3 From 3126e00a28e498afc8ecef1ed87b356f0e4a38c4 Mon Sep 17 00:00:00 2001 From: Vivaan Verma Date: Sun, 18 Apr 2021 22:11:46 +0100 Subject: Make duration an optional arg and default it to 1 hour --- bot/exts/moderation/infraction/superstarify.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 245f14905..8a6d14d41 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -1,3 +1,4 @@ +import datetime import json import logging import random @@ -109,7 +110,7 @@ class Superstarify(InfractionScheduler, Cog): self, ctx: Context, member: Member, - duration: Expiry = "1h", + duration: t.Optional[Expiry], *, reason: str = '', ) -> None: @@ -134,6 +135,9 @@ class Superstarify(InfractionScheduler, Cog): if await _utils.get_active_infraction(ctx, member, "superstar"): return + # Set the duration to 1 hour if none was provided + duration = datetime.datetime.now() + datetime.timedelta(hours=1) + # Post the infraction to the API old_nick = member.display_name infraction_reason = f'Old nickname: {old_nick}. {reason}' -- cgit v1.2.3 From 7fc5e37ecd2e1589b77b7fa16af26ee42e72dcdc Mon Sep 17 00:00:00 2001 From: Vivaan Verma Date: Sun, 18 Apr 2021 22:17:27 +0100 Subject: Check if a duration was provided --- bot/exts/moderation/infraction/superstarify.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 8a6d14d41..f5d6259cd 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -136,8 +136,9 @@ class Superstarify(InfractionScheduler, Cog): return # Set the duration to 1 hour if none was provided - duration = datetime.datetime.now() + datetime.timedelta(hours=1) - + if not duration: + duration = datetime.datetime.now() + datetime.timedelta(hours=1) + # Post the infraction to the API old_nick = member.display_name infraction_reason = f'Old nickname: {old_nick}. {reason}' -- cgit v1.2.3 From 6169ed2b73a5f2d763a2758e69ba4983127a1373 Mon Sep 17 00:00:00 2001 From: Vivaan Verma <54081925+doublevcodes@users.noreply.github.com> Date: Sun, 18 Apr 2021 22:31:40 +0100 Subject: Fix linting errors --- bot/exts/moderation/infraction/superstarify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index f5d6259cd..6fa0d550f 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -138,7 +138,7 @@ class Superstarify(InfractionScheduler, Cog): # Set the duration to 1 hour if none was provided if not duration: duration = datetime.datetime.now() + datetime.timedelta(hours=1) - + # Post the infraction to the API old_nick = member.display_name infraction_reason = f'Old nickname: {old_nick}. {reason}' -- cgit v1.2.3 From bd54449e8994c38b2fd073056f82e6c52785d4c6 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Mon, 19 Apr 2021 15:43:33 +0300 Subject: Renamed Duty cog to Modpings The renaming includes the commands inside it. --- bot/exts/moderation/duty.py | 46 ++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/bot/exts/moderation/duty.py b/bot/exts/moderation/duty.py index e05472448..c351db615 100644 --- a/bot/exts/moderation/duty.py +++ b/bot/exts/moderation/duty.py @@ -14,13 +14,13 @@ from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) -class Duty(Cog): - """Commands for a moderator to go on and off duty.""" +class Modpings(Cog): + """Commands for a moderator to turn moderator pings on and off.""" # RedisCache[str, str] - # The cache's keys are mods who are off-duty. + # The cache's keys are mods who have pings off. # The cache's values are the times when the role should be re-applied to them, stored in ISO format. - off_duty_mods = RedisCache() + pings_off_mods = RedisCache() def __init__(self, bot: Bot): self.bot = bot @@ -29,7 +29,7 @@ class Duty(Cog): self.guild = None self.moderators_role = None - self.reschedule_task = self.bot.loop.create_task(self.reschedule_roles(), name="duty-reschedule") + self.reschedule_task = self.bot.loop.create_task(self.reschedule_roles(), name="mod-pings-reschedule") async def reschedule_roles(self) -> None: """Reschedule moderators role re-apply times.""" @@ -38,35 +38,35 @@ class Duty(Cog): self.moderators_role = self.guild.get_role(Roles.moderators) mod_team = self.guild.get_role(Roles.mod_team) - on_duty = self.moderators_role.members - off_duty = await self.off_duty_mods.to_dict() + pings_on = self.moderators_role.members + pings_off = await self.pings_off_mods.to_dict() log.trace("Applying the moderators role to the mod team where necessary.") for mod in mod_team.members: - if mod in on_duty: # Make sure that on-duty mods aren't in the cache. - if mod in off_duty: - await self.off_duty_mods.delete(mod.id) + if mod in pings_on: # Make sure that on-duty mods aren't in the cache. + if mod in pings_off: + await self.pings_off_mods.delete(mod.id) continue # Keep the role off only for those in the cache. - if mod.id not in off_duty: + if mod.id not in pings_off: await self.reapply_role(mod) else: - expiry = isoparse(off_duty[mod.id]).replace(tzinfo=None) + expiry = isoparse(pings_off[mod.id]).replace(tzinfo=None) self._role_scheduler.schedule_at(expiry, mod.id, self.reapply_role(mod)) async def reapply_role(self, mod: Member) -> None: """Reapply the moderator's role to the given moderator.""" log.trace(f"Re-applying role to mod with ID {mod.id}.") - await mod.add_roles(self.moderators_role, reason="Off-duty period expired.") + await mod.add_roles(self.moderators_role, reason="Pings off period expired.") - @group(name='duty', invoke_without_command=True) + @group(name='modpings', aliases=('modping',), invoke_without_command=True) @has_any_role(*MODERATION_ROLES) - async def duty_group(self, ctx: Context) -> None: + async def modpings_group(self, ctx: Context) -> None: """Allow the removal and re-addition of the pingable moderators role.""" await ctx.send_help(ctx.command) - @duty_group.command(name='off') + @modpings_group.command(name='off') @has_any_role(*MODERATION_ROLES) async def off_command(self, ctx: Context, duration: Expiry) -> None: """ @@ -95,9 +95,9 @@ class Duty(Cog): mod = ctx.author until_date = duration.replace(microsecond=0).isoformat() # Looks noisy with microseconds. - await mod.remove_roles(self.moderators_role, reason=f"Entered off-duty period until {until_date}.") + await mod.remove_roles(self.moderators_role, reason=f"Turned pings off until {until_date}.") - await self.off_duty_mods.set(mod.id, duration.isoformat()) + await self.pings_off_mods.set(mod.id, duration.isoformat()) # Allow rescheduling the task without cancelling it separately via the `on` command. if mod.id in self._role_scheduler: @@ -106,7 +106,7 @@ class Duty(Cog): await ctx.send(f"{Emojis.check_mark} Moderators role has been removed until {until_date}.") - @duty_group.command(name='on') + @modpings_group.command(name='on') @has_any_role(*MODERATION_ROLES) async def on_command(self, ctx: Context) -> None: """Re-apply the pingable moderators role.""" @@ -115,9 +115,9 @@ class Duty(Cog): await ctx.send(":question: You already have the role.") return - await mod.add_roles(self.moderators_role, reason="Off-duty period canceled.") + await mod.add_roles(self.moderators_role, reason="Pings off period canceled.") - await self.off_duty_mods.delete(mod.id) + await self.pings_off_mods.delete(mod.id) # We assume the task exists. Lack of it may indicate a bug. self._role_scheduler.cancel(mod.id) @@ -132,5 +132,5 @@ class Duty(Cog): def setup(bot: Bot) -> None: - """Load the Duty cog.""" - bot.add_cog(Duty(bot)) + """Load the Modpings cog.""" + bot.add_cog(Modpings(bot)) -- cgit v1.2.3 From e30667fb4e23648c3f308bfc06cf643852d0c29c Mon Sep 17 00:00:00 2001 From: mbaruh Date: Mon, 19 Apr 2021 15:44:58 +0300 Subject: Renamed duty.py to modpings.py --- bot/exts/moderation/duty.py | 136 ---------------------------------------- bot/exts/moderation/modpings.py | 136 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 136 deletions(-) delete mode 100644 bot/exts/moderation/duty.py create mode 100644 bot/exts/moderation/modpings.py diff --git a/bot/exts/moderation/duty.py b/bot/exts/moderation/duty.py deleted file mode 100644 index c351db615..000000000 --- a/bot/exts/moderation/duty.py +++ /dev/null @@ -1,136 +0,0 @@ -import datetime -import logging - -from async_rediscache import RedisCache -from dateutil.parser import isoparse -from discord import Member -from discord.ext.commands import Cog, Context, group, has_any_role - -from bot.bot import Bot -from bot.constants import Emojis, Guild, MODERATION_ROLES, Roles -from bot.converters import Expiry -from bot.utils.scheduling import Scheduler - -log = logging.getLogger(__name__) - - -class Modpings(Cog): - """Commands for a moderator to turn moderator pings on and off.""" - - # RedisCache[str, str] - # The cache's keys are mods who have pings off. - # The cache's values are the times when the role should be re-applied to them, stored in ISO format. - pings_off_mods = RedisCache() - - def __init__(self, bot: Bot): - self.bot = bot - self._role_scheduler = Scheduler(self.__class__.__name__) - - self.guild = None - self.moderators_role = None - - self.reschedule_task = self.bot.loop.create_task(self.reschedule_roles(), name="mod-pings-reschedule") - - async def reschedule_roles(self) -> None: - """Reschedule moderators role re-apply times.""" - await self.bot.wait_until_guild_available() - self.guild = self.bot.get_guild(Guild.id) - self.moderators_role = self.guild.get_role(Roles.moderators) - - mod_team = self.guild.get_role(Roles.mod_team) - pings_on = self.moderators_role.members - pings_off = await self.pings_off_mods.to_dict() - - log.trace("Applying the moderators role to the mod team where necessary.") - for mod in mod_team.members: - if mod in pings_on: # Make sure that on-duty mods aren't in the cache. - if mod in pings_off: - await self.pings_off_mods.delete(mod.id) - continue - - # Keep the role off only for those in the cache. - if mod.id not in pings_off: - await self.reapply_role(mod) - else: - expiry = isoparse(pings_off[mod.id]).replace(tzinfo=None) - self._role_scheduler.schedule_at(expiry, mod.id, self.reapply_role(mod)) - - async def reapply_role(self, mod: Member) -> None: - """Reapply the moderator's role to the given moderator.""" - log.trace(f"Re-applying role to mod with ID {mod.id}.") - await mod.add_roles(self.moderators_role, reason="Pings off period expired.") - - @group(name='modpings', aliases=('modping',), invoke_without_command=True) - @has_any_role(*MODERATION_ROLES) - async def modpings_group(self, ctx: Context) -> None: - """Allow the removal and re-addition of the pingable moderators role.""" - await ctx.send_help(ctx.command) - - @modpings_group.command(name='off') - @has_any_role(*MODERATION_ROLES) - async def off_command(self, ctx: Context, duration: Expiry) -> None: - """ - Temporarily removes the pingable moderators role for a set amount of time. - - A unit of time should be appended to the duration. - Units (∗case-sensitive): - \u2003`y` - years - \u2003`m` - months∗ - \u2003`w` - weeks - \u2003`d` - days - \u2003`h` - hours - \u2003`M` - minutes∗ - \u2003`s` - seconds - - Alternatively, an ISO 8601 timestamp can be provided for the duration. - - The duration cannot be longer than 30 days. - """ - duration: datetime.datetime - delta = duration - datetime.datetime.utcnow() - if delta > datetime.timedelta(days=30): - await ctx.send(":x: Cannot remove the role for longer than 30 days.") - return - - mod = ctx.author - - until_date = duration.replace(microsecond=0).isoformat() # Looks noisy with microseconds. - await mod.remove_roles(self.moderators_role, reason=f"Turned pings off until {until_date}.") - - await self.pings_off_mods.set(mod.id, duration.isoformat()) - - # Allow rescheduling the task without cancelling it separately via the `on` command. - if mod.id in self._role_scheduler: - self._role_scheduler.cancel(mod.id) - self._role_scheduler.schedule_at(duration, mod.id, self.reapply_role(mod)) - - await ctx.send(f"{Emojis.check_mark} Moderators role has been removed until {until_date}.") - - @modpings_group.command(name='on') - @has_any_role(*MODERATION_ROLES) - async def on_command(self, ctx: Context) -> None: - """Re-apply the pingable moderators role.""" - mod = ctx.author - if mod in self.moderators_role.members: - await ctx.send(":question: You already have the role.") - return - - await mod.add_roles(self.moderators_role, reason="Pings off period canceled.") - - await self.pings_off_mods.delete(mod.id) - - # We assume the task exists. Lack of it may indicate a bug. - self._role_scheduler.cancel(mod.id) - - await ctx.send(f"{Emojis.check_mark} Moderators role has been re-applied.") - - def cog_unload(self) -> None: - """Cancel role tasks when the cog unloads.""" - log.trace("Cog unload: canceling role tasks.") - self.reschedule_task.cancel() - self._role_scheduler.cancel_all() - - -def setup(bot: Bot) -> None: - """Load the Modpings cog.""" - bot.add_cog(Modpings(bot)) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py new file mode 100644 index 000000000..c351db615 --- /dev/null +++ b/bot/exts/moderation/modpings.py @@ -0,0 +1,136 @@ +import datetime +import logging + +from async_rediscache import RedisCache +from dateutil.parser import isoparse +from discord import Member +from discord.ext.commands import Cog, Context, group, has_any_role + +from bot.bot import Bot +from bot.constants import Emojis, Guild, MODERATION_ROLES, Roles +from bot.converters import Expiry +from bot.utils.scheduling import Scheduler + +log = logging.getLogger(__name__) + + +class Modpings(Cog): + """Commands for a moderator to turn moderator pings on and off.""" + + # RedisCache[str, str] + # The cache's keys are mods who have pings off. + # The cache's values are the times when the role should be re-applied to them, stored in ISO format. + pings_off_mods = RedisCache() + + def __init__(self, bot: Bot): + self.bot = bot + self._role_scheduler = Scheduler(self.__class__.__name__) + + self.guild = None + self.moderators_role = None + + self.reschedule_task = self.bot.loop.create_task(self.reschedule_roles(), name="mod-pings-reschedule") + + async def reschedule_roles(self) -> None: + """Reschedule moderators role re-apply times.""" + await self.bot.wait_until_guild_available() + self.guild = self.bot.get_guild(Guild.id) + self.moderators_role = self.guild.get_role(Roles.moderators) + + mod_team = self.guild.get_role(Roles.mod_team) + pings_on = self.moderators_role.members + pings_off = await self.pings_off_mods.to_dict() + + log.trace("Applying the moderators role to the mod team where necessary.") + for mod in mod_team.members: + if mod in pings_on: # Make sure that on-duty mods aren't in the cache. + if mod in pings_off: + await self.pings_off_mods.delete(mod.id) + continue + + # Keep the role off only for those in the cache. + if mod.id not in pings_off: + await self.reapply_role(mod) + else: + expiry = isoparse(pings_off[mod.id]).replace(tzinfo=None) + self._role_scheduler.schedule_at(expiry, mod.id, self.reapply_role(mod)) + + async def reapply_role(self, mod: Member) -> None: + """Reapply the moderator's role to the given moderator.""" + log.trace(f"Re-applying role to mod with ID {mod.id}.") + await mod.add_roles(self.moderators_role, reason="Pings off period expired.") + + @group(name='modpings', aliases=('modping',), invoke_without_command=True) + @has_any_role(*MODERATION_ROLES) + async def modpings_group(self, ctx: Context) -> None: + """Allow the removal and re-addition of the pingable moderators role.""" + await ctx.send_help(ctx.command) + + @modpings_group.command(name='off') + @has_any_role(*MODERATION_ROLES) + async def off_command(self, ctx: Context, duration: Expiry) -> None: + """ + Temporarily removes the pingable moderators role for a set amount of time. + + A unit of time should be appended to the duration. + Units (∗case-sensitive): + \u2003`y` - years + \u2003`m` - months∗ + \u2003`w` - weeks + \u2003`d` - days + \u2003`h` - hours + \u2003`M` - minutes∗ + \u2003`s` - seconds + + Alternatively, an ISO 8601 timestamp can be provided for the duration. + + The duration cannot be longer than 30 days. + """ + duration: datetime.datetime + delta = duration - datetime.datetime.utcnow() + if delta > datetime.timedelta(days=30): + await ctx.send(":x: Cannot remove the role for longer than 30 days.") + return + + mod = ctx.author + + until_date = duration.replace(microsecond=0).isoformat() # Looks noisy with microseconds. + await mod.remove_roles(self.moderators_role, reason=f"Turned pings off until {until_date}.") + + await self.pings_off_mods.set(mod.id, duration.isoformat()) + + # Allow rescheduling the task without cancelling it separately via the `on` command. + if mod.id in self._role_scheduler: + self._role_scheduler.cancel(mod.id) + self._role_scheduler.schedule_at(duration, mod.id, self.reapply_role(mod)) + + await ctx.send(f"{Emojis.check_mark} Moderators role has been removed until {until_date}.") + + @modpings_group.command(name='on') + @has_any_role(*MODERATION_ROLES) + async def on_command(self, ctx: Context) -> None: + """Re-apply the pingable moderators role.""" + mod = ctx.author + if mod in self.moderators_role.members: + await ctx.send(":question: You already have the role.") + return + + await mod.add_roles(self.moderators_role, reason="Pings off period canceled.") + + await self.pings_off_mods.delete(mod.id) + + # We assume the task exists. Lack of it may indicate a bug. + self._role_scheduler.cancel(mod.id) + + await ctx.send(f"{Emojis.check_mark} Moderators role has been re-applied.") + + def cog_unload(self) -> None: + """Cancel role tasks when the cog unloads.""" + log.trace("Cog unload: canceling role tasks.") + self.reschedule_task.cancel() + self._role_scheduler.cancel_all() + + +def setup(bot: Bot) -> None: + """Load the Modpings cog.""" + bot.add_cog(Modpings(bot)) -- cgit v1.2.3 From 0204f7cc73bcf803fe86ca45cbdca19432b83cb6 Mon Sep 17 00:00:00 2001 From: francisdbillones <57383750+francisdbillones@users.noreply.github.com> Date: Mon, 19 Apr 2021 21:42:40 +0800 Subject: Fix zen's negative indexing Negative indexing starts at -1, not 0, meaning lower bound should be -1 * len(zen_lines), not -1 * upper_bound. --- bot/exts/utils/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 8d9d27c64..4c39a7c2a 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -109,7 +109,7 @@ class Utils(Cog): # handle if it's an index int if isinstance(search_value, int): upper_bound = len(zen_lines) - 1 - lower_bound = -1 * upper_bound + lower_bound = -1 * len(zen_lines) if not (lower_bound <= search_value <= upper_bound): raise BadArgument(f"Please provide an index between {lower_bound} and {upper_bound}.") -- cgit v1.2.3 From 2ede01f32a49c3c1d4376b542789e770106711bc Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 19 Apr 2021 15:46:14 +0200 Subject: Add blacklist format to the BOT_TRACE_LOGGERS env var To mimic the same behaviour, setting all of the loggers to the trace level was changed to a "*" prefix without looking at other contents instead of setting it exactly to "ROOT" or "*" --- bot/log.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/bot/log.py b/bot/log.py index 339ed63a7..4e20c005e 100644 --- a/bot/log.py +++ b/bot/log.py @@ -87,10 +87,27 @@ def _monkeypatch_trace(self: logging.Logger, msg: str, *args, **kwargs) -> None: def _set_trace_loggers() -> None: - """Set loggers to the trace level according to the value from the BOT_TRACE_LOGGERS env var.""" - if constants.Bot.trace_loggers: - if constants.Bot.trace_loggers in {"*", "ROOT"}: + """ + Set loggers to the trace level according to the value from the BOT_TRACE_LOGGERS env var. + + When the env var is a list of logger names delimited by a comma, + each of the listed loggers will be set to the trace level. + + If this list is prefixed with a "!", all of the loggers except the listed ones will be set to the trace level. + + Otherwise if the env var begins with a "*", + the root logger is set to the trace level and other contents are ignored. + """ + level_filter = constants.Bot.trace_loggers + if level_filter: + if level_filter.startswith("*"): + logging.getLogger().setLevel(logging.TRACE) + + elif level_filter.startswith("!"): logging.getLogger().setLevel(logging.TRACE) + for logger_name in level_filter.strip("!,").split(","): + logging.getLogger(logger_name).setLevel(logging.DEBUG) + else: - for logger_name in constants.Bot.trace_loggers.split(","): + for logger_name in level_filter.strip(",").split(","): logging.getLogger(logger_name).setLevel(logging.TRACE) -- cgit v1.2.3 From cb253750a5597d8ca63e8742307bafc096c7e189 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 19 Apr 2021 18:05:11 +0100 Subject: Require a mod role for stream commands Previously any staff member (including helpers) could use the stream commands. --- bot/exts/moderation/stream.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index 12e195172..7ea7f635b 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -8,7 +8,7 @@ from async_rediscache import RedisCache from discord.ext import commands from bot.bot import Bot -from bot.constants import Colours, Emojis, Guild, Roles, STAFF_ROLES, VideoPermission +from bot.constants import Colours, Emojis, Guild, MODERATION_ROLES, Roles, VideoPermission from bot.converters import Expiry from bot.utils.scheduling import Scheduler from bot.utils.time import format_infraction_with_duration @@ -69,7 +69,7 @@ class Stream(commands.Cog): ) @commands.command(aliases=("streaming",)) - @commands.has_any_role(*STAFF_ROLES) + @commands.has_any_role(*MODERATION_ROLES) async def stream(self, ctx: commands.Context, member: discord.Member, duration: Expiry = None) -> None: """ Temporarily grant streaming permissions to a member for a given duration. @@ -126,7 +126,7 @@ class Stream(commands.Cog): log.debug(f"Successfully gave {member} ({member.id}) permission to stream until {revoke_time}.") @commands.command(aliases=("pstream",)) - @commands.has_any_role(*STAFF_ROLES) + @commands.has_any_role(*MODERATION_ROLES) async def permanentstream(self, ctx: commands.Context, member: discord.Member) -> None: """Permanently grants the given member the permission to stream.""" log.trace(f"Attempting to give permanent streaming permission to {member} ({member.id}).") @@ -153,7 +153,7 @@ class Stream(commands.Cog): log.debug(f"Successfully gave {member} ({member.id}) permanent streaming permission.") @commands.command(aliases=("unstream", "rstream")) - @commands.has_any_role(*STAFF_ROLES) + @commands.has_any_role(*MODERATION_ROLES) async def revokestream(self, ctx: commands.Context, member: discord.Member) -> None: """Revoke the permission to stream from the given member.""" log.trace(f"Attempting to remove streaming permission from {member} ({member.id}).") -- cgit v1.2.3 From 90ed28f4cb31b5b41f7a395abfe61f4f9e49e091 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 19 Apr 2021 18:08:39 +0100 Subject: Add command to list users with streaming perms This is useful to audit users who still have the permission to stream. I have chosen to also sort and paginate the embed to make it easier to read. The sorting is based on how long until the user's streaming permissions are revoked, with permanent streamers at the end. --- bot/exts/moderation/stream.py | 44 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index 7ea7f635b..5f3820748 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -1,5 +1,6 @@ import logging from datetime import timedelta, timezone +from operator import itemgetter import arrow import discord @@ -8,8 +9,9 @@ from async_rediscache import RedisCache from discord.ext import commands from bot.bot import Bot -from bot.constants import Colours, Emojis, Guild, MODERATION_ROLES, Roles, VideoPermission +from bot.constants import Colours, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES, VideoPermission from bot.converters import Expiry +from bot.pagination import LinePaginator from bot.utils.scheduling import Scheduler from bot.utils.time import format_infraction_with_duration @@ -173,6 +175,46 @@ class Stream(commands.Cog): await ctx.send(f"{Emojis.cross_mark} This member doesn't have video permissions to remove!") log.debug(f"{member} ({member.id}) didn't have the streaming permission to remove!") + @commands.command(aliases=('lstream',)) + @commands.has_any_role(*MODERATION_ROLES) + async def liststream(self, ctx: commands.Context) -> None: + """Lists all non-staff users who have permission to stream.""" + non_staff_members_with_stream = [ + _member + for _member in ctx.guild.get_role(Roles.video).members + if not any(role.id in STAFF_ROLES for role in _member.roles) + ] + + # List of tuples (UtcPosixTimestamp, str) + # This is so that we can sort before outputting to the paginator + streamer_info = [] + for member in non_staff_members_with_stream: + if revoke_time := await self.task_cache.get(member.id): + # Member only has temporary streaming perms + revoke_delta = Arrow.utcfromtimestamp(revoke_time).humanize() + message = f"{member.mention} will have stream permissions revoked {revoke_delta}." + else: + message = f"{member.mention} has permanent streaming permissions." + + # If revoke_time is None use max timestamp to force sort to put them at the end + streamer_info.append( + (revoke_time or Arrow.max.timestamp(), message) + ) + + if streamer_info: + # Sort based on duration left of streaming perms + streamer_info.sort(key=itemgetter(0)) + + # Only output the message in the pagination + lines = [line[1] for line in streamer_info] + embed = discord.Embed( + title=f"Members who can stream (`{len(lines)}` total)", + colour=Colours.soft_green + ) + await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False) + else: + await ctx.send("No members with stream permissions found.") + def setup(bot: Bot) -> None: """Loads the Stream cog.""" -- cgit v1.2.3 From a6b76092e6e6005fc98c9863db051804d7bb963a Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 19 Apr 2021 18:16:42 +0100 Subject: Update wording of comment to be clearer. --- bot/exts/moderation/stream.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index 5f3820748..d9837b5ed 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -186,7 +186,7 @@ class Stream(commands.Cog): ] # List of tuples (UtcPosixTimestamp, str) - # This is so that we can sort before outputting to the paginator + # This is so that output can be sorted on [0] before passed it's to the paginator streamer_info = [] for member in non_staff_members_with_stream: if revoke_time := await self.task_cache.get(member.id): -- cgit v1.2.3 From 94db90b038574077beb2fafb4f17741061ee8152 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 19 Apr 2021 18:23:34 +0100 Subject: Remove unnecessary _ in variable name --- bot/exts/moderation/stream.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index d9837b5ed..e541baeb2 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -180,9 +180,9 @@ class Stream(commands.Cog): async def liststream(self, ctx: commands.Context) -> None: """Lists all non-staff users who have permission to stream.""" non_staff_members_with_stream = [ - _member - for _member in ctx.guild.get_role(Roles.video).members - if not any(role.id in STAFF_ROLES for role in _member.roles) + member + for member in ctx.guild.get_role(Roles.video).members + if not any(role.id in STAFF_ROLES for role in member.roles) ] # List of tuples (UtcPosixTimestamp, str) -- cgit v1.2.3 From 131dab3754da9fc1c3cf770d76bb9deea46f2f8d Mon Sep 17 00:00:00 2001 From: ChrisJL Date: Mon, 19 Apr 2021 18:40:23 +0100 Subject: Improve the wording of the list streamers embed Co-authored-by: Matteo Bertucci --- bot/exts/moderation/stream.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index e541baeb2..bd93ea492 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -208,7 +208,7 @@ class Stream(commands.Cog): # Only output the message in the pagination lines = [line[1] for line in streamer_info] embed = discord.Embed( - title=f"Members who can stream (`{len(lines)}` total)", + title=f"Members with streaming permission (`{len(lines)}` total)", colour=Colours.soft_green ) await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False) -- cgit v1.2.3 From a7581a4f9f2724672eebfdf541a922973c018c23 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Mon, 19 Apr 2021 20:48:26 +0300 Subject: CamelCase the cog name --- bot/exts/moderation/modpings.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index c351db615..690aa7c68 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -14,7 +14,7 @@ from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) -class Modpings(Cog): +class ModPings(Cog): """Commands for a moderator to turn moderator pings on and off.""" # RedisCache[str, str] @@ -132,5 +132,5 @@ class Modpings(Cog): def setup(bot: Bot) -> None: - """Load the Modpings cog.""" - bot.add_cog(Modpings(bot)) + """Load the ModPings cog.""" + bot.add_cog(ModPings(bot)) -- cgit v1.2.3 From c001456cf29f944deb632b28130fb16a170092e9 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 19 Apr 2021 18:49:09 +0100 Subject: Update comment in list stream for readibility --- bot/exts/moderation/stream.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index bd93ea492..1dbb2a46b 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -186,7 +186,7 @@ class Stream(commands.Cog): ] # List of tuples (UtcPosixTimestamp, str) - # This is so that output can be sorted on [0] before passed it's to the paginator + # So that the list can be sorted on the UtcPosixTimestamp before the message is passed to the paginator. streamer_info = [] for member in non_staff_members_with_stream: if revoke_time := await self.task_cache.get(member.id): -- cgit v1.2.3 From 40d21cf112b28858aad2508bf147b019314dd4ee Mon Sep 17 00:00:00 2001 From: Rohan Date: Mon, 19 Apr 2021 23:55:55 +0530 Subject: Add afk voice channel to constants. --- bot/constants.py | 1 + config-default.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 6d14bbb3a..b9444c989 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -444,6 +444,7 @@ class Channels(metaclass=YAMLGetter): mod_announcements: int staff_announcements: int + afk_voice: int admins_voice: int code_help_voice_1: int code_help_voice_2: int diff --git a/config-default.yml b/config-default.yml index 8c6e18470..204397f7f 100644 --- a/config-default.yml +++ b/config-default.yml @@ -206,6 +206,7 @@ guild: staff_announcements: &STAFF_ANNOUNCEMENTS 464033278631084042 # Voice Channels + afk_voice: 756327105389920306 admins_voice: &ADMINS_VOICE 500734494840717332 code_help_voice_1: 751592231726481530 code_help_voice_2: 764232549840846858 -- cgit v1.2.3 From fbbe1a861aa725d1f327716177b383ea38f20f0c Mon Sep 17 00:00:00 2001 From: Rohan Date: Tue, 20 Apr 2021 00:02:22 +0530 Subject: Add method for suspending member's stream when revoking stream perms. --- bot/exts/moderation/stream.py | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index 1dbb2a46b..a2ebb6205 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -9,7 +9,7 @@ from async_rediscache import RedisCache from discord.ext import commands from bot.bot import Bot -from bot.constants import Colours, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES, VideoPermission +from bot.constants import Channels, Colours, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES, VideoPermission from bot.converters import Expiry from bot.pagination import LinePaginator from bot.utils.scheduling import Scheduler @@ -70,6 +70,27 @@ class Stream(commands.Cog): self._revoke_streaming_permission(member) ) + async def _suspend_stream(self, ctx: commands.Context, member: discord.Member) -> None: + """Suspend a member's stream.""" + voice_state = member.voice + + if not voice_state: + return + + # If the user is streaming. + if voice_state.self_stream: + # End user's stream by moving them to AFK voice channel and back. + original_vc = voice_state.channel + await member.move_to(self.bot.get_channel(Channels.afk_voice)) + await member.move_to(original_vc) + + # Notify. + await ctx.send(f"{member.mention}'s stream has been suspended!") + log.debug(f"Successfully suspended stream from {member} ({member.id}).") + return + + log.debug(f"No stream found to suspend from {member} ({member.id}).") + @commands.command(aliases=("streaming",)) @commands.has_any_role(*MODERATION_ROLES) async def stream(self, ctx: commands.Context, member: discord.Member, duration: Expiry = None) -> None: @@ -170,10 +191,12 @@ class Stream(commands.Cog): await ctx.send(f"{Emojis.check_mark} Revoked the permission to stream from {member.mention}.") log.debug(f"Successfully revoked streaming permission from {member} ({member.id}).") - return - await ctx.send(f"{Emojis.cross_mark} This member doesn't have video permissions to remove!") - log.debug(f"{member} ({member.id}) didn't have the streaming permission to remove!") + else: + await ctx.send(f"{Emojis.cross_mark} This member doesn't have video permissions to remove!") + log.debug(f"{member} ({member.id}) didn't have the streaming permission to remove!") + + await self._suspend_stream(ctx, member) @commands.command(aliases=('lstream',)) @commands.has_any_role(*MODERATION_ROLES) -- cgit v1.2.3 From b8b920bfa5c4d918d41bfe06d85b1e85f4bec0da Mon Sep 17 00:00:00 2001 From: Vivaan Verma <54081925+doublevcodes@users.noreply.github.com> Date: Mon, 19 Apr 2021 20:01:41 +0100 Subject: Inline duration assignment Co-authored-by: Rohan Reddy Alleti --- bot/exts/moderation/infraction/superstarify.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 6fa0d550f..3d880dec3 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -136,8 +136,7 @@ class Superstarify(InfractionScheduler, Cog): return # Set the duration to 1 hour if none was provided - if not duration: - duration = datetime.datetime.now() + datetime.timedelta(hours=1) + duration = duration or datetime.datetime.utcnow() + datetime.timedelta(hours=1) # Post the infraction to the API old_nick = member.display_name -- cgit v1.2.3 From ae5d1cb65ddec0e70df00a4051a5bf813d4e6e20 Mon Sep 17 00:00:00 2001 From: Vivaan Verma Date: Mon, 19 Apr 2021 21:06:15 +0100 Subject: Add default duration as constant and use Duration converter --- bot/exts/moderation/infraction/superstarify.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 3d880dec3..0bc2198c3 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -1,4 +1,3 @@ -import datetime import json import logging import random @@ -12,7 +11,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.converters import Expiry +from bot.converters import Duration from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler from bot.utils.messages import format_user @@ -20,6 +19,7 @@ from bot.utils.time import format_infraction log = logging.getLogger(__name__) NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy" +SUPERSTARIFY_DEFAULT_DURATION = "1h" with Path("bot/resources/stars.json").open(encoding="utf-8") as stars_file: STAR_NAMES = json.load(stars_file) @@ -110,7 +110,7 @@ class Superstarify(InfractionScheduler, Cog): self, ctx: Context, member: Member, - duration: t.Optional[Expiry], + duration: t.Optional[Duration], *, reason: str = '', ) -> None: @@ -136,7 +136,7 @@ class Superstarify(InfractionScheduler, Cog): return # Set the duration to 1 hour if none was provided - duration = duration or datetime.datetime.utcnow() + datetime.timedelta(hours=1) + duration = duration or await Duration().convert(ctx, SUPERSTARIFY_DEFAULT_DURATION) # Post the infraction to the API old_nick = member.display_name -- cgit v1.2.3 From 03f909df6758a10c95f0b63df487f1acd97ec36d Mon Sep 17 00:00:00 2001 From: Vivaan Verma Date: Mon, 19 Apr 2021 21:15:11 +0100 Subject: Change type hint from duration to expiry --- bot/exts/moderation/infraction/superstarify.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 0bc2198c3..ef88fb43f 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -11,7 +11,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.converters import Duration +from bot.converters import Duration, Expiry from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler from bot.utils.messages import format_user @@ -110,7 +110,7 @@ class Superstarify(InfractionScheduler, Cog): self, ctx: Context, member: Member, - duration: t.Optional[Duration], + duration: t.Optional[Expiry], *, reason: str = '', ) -> None: -- cgit v1.2.3 From 91bdf9415ec88715fadf2e0a56b900b376b638db Mon Sep 17 00:00:00 2001 From: Vivaan Verma <54081925+doublevcodes@users.noreply.github.com> Date: Mon, 19 Apr 2021 22:02:45 +0100 Subject: Update bot/exts/moderation/infraction/superstarify.py Co-authored-by: Boris Muratov <8bee278@gmail.com> --- bot/exts/moderation/infraction/superstarify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index ef88fb43f..07e79b9fe 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -135,7 +135,7 @@ class Superstarify(InfractionScheduler, Cog): if await _utils.get_active_infraction(ctx, member, "superstar"): return - # Set the duration to 1 hour if none was provided + # Set to default duration if none was provided. duration = duration or await Duration().convert(ctx, SUPERSTARIFY_DEFAULT_DURATION) # Post the infraction to the API -- cgit v1.2.3 From 9aa2b42aa04724a4ebc74d3ff6c339c33547dce3 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 20 Apr 2021 17:20:44 +0200 Subject: Tests: AsyncMock is now in the standard library! The `tests/README.md` file still referenced our old custom `AsyncMock` that has been removed in favour of the standard library one that has been introduced in 3.8. This commit fixes this by updating the section. --- tests/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/README.md b/tests/README.md index 4f62edd68..092324123 100644 --- a/tests/README.md +++ b/tests/README.md @@ -114,7 +114,7 @@ class BotCogTests(unittest.TestCase): ### Mocking coroutines -By default, the `unittest.mock.Mock` and `unittest.mock.MagicMock` classes cannot mock coroutines, since the `__call__` method they provide is synchronous. In anticipation of the `AsyncMock` that will be [introduced in Python 3.8](https://docs.python.org/3.9/whatsnew/3.8.html#unittest), we have added an `AsyncMock` helper to [`helpers.py`](/tests/helpers.py). Do note that this drop-in replacement only implements an asynchronous `__call__` method, not the additional assertions that will come with the new `AsyncMock` type in Python 3.8. +By default, the `unittest.mock.Mock` and `unittest.mock.MagicMock` classes cannot mock coroutines, since the `__call__` method they provide is synchronous. The [`AsyncMock`](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.AsyncMock) that has been [introduced in Python 3.8](https://docs.python.org/3.9/whatsnew/3.8.html#unittest) is an asynchronous version of `MagicMock` that can be used anywhere a coroutine is expected. ### Special mocks for some `discord.py` types -- cgit v1.2.3 From b12666dc4b75146b150c0812c5cb56f4317773ae Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Tue, 20 Apr 2021 18:48:12 +0300 Subject: Improve rediscache doc Co-authored-by: ChrisJL --- bot/exts/moderation/modpings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 690aa7c68..2f180e594 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -17,7 +17,7 @@ log = logging.getLogger(__name__) class ModPings(Cog): """Commands for a moderator to turn moderator pings on and off.""" - # RedisCache[str, str] + # RedisCache[discord.Member.id, 'Naïve ISO 8601 string'] # The cache's keys are mods who have pings off. # The cache's values are the times when the role should be re-applied to them, stored in ISO format. pings_off_mods = RedisCache() -- cgit v1.2.3 From 8a73d2b5e71444595b72155d7106c0fc48eeb027 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Tue, 20 Apr 2021 19:14:10 +0300 Subject: Remove allowed mentions in modlog alert The modlog alert embed no longer pings everyone. --- bot/exts/moderation/modlog.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index 5e8ea595b..e92f76c9a 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -127,8 +127,7 @@ class ModLog(Cog, name="ModLog"): log_message = await channel.send( content=content, embed=embed, - files=files, - allowed_mentions=discord.AllowedMentions(everyone=True) + files=files ) if additional_embeds: -- cgit v1.2.3 From c20f84ff95671527e6fbacb04f07bcee3baaafcd Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 20 Apr 2021 17:54:44 +0100 Subject: Add the Moderators role to moderation_roles in config This allows mod alert pings to go through in #mod-alerts, the allowed mentions only included the mods team role which is not pinged on mod alerts. --- config-default.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config-default.yml b/config-default.yml index b19164d3f..b7c446889 100644 --- a/config-default.yml +++ b/config-default.yml @@ -275,6 +275,7 @@ guild: moderation_roles: - *ADMINS_ROLE - *MOD_TEAM_ROLE + - *MODS_ROLE - *OWNERS_ROLE staff_roles: -- cgit v1.2.3 From 1a65e2a0505c719a77ccf9b0832f44ac035c4f1c Mon Sep 17 00:00:00 2001 From: ToxicKidz <78174417+ToxicKidz@users.noreply.github.com> Date: Tue, 20 Apr 2021 18:07:16 -0400 Subject: chore: Use Embed.timestamp for showing when the reminder will be sent --- bot/exts/utils/reminders.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 3113a1149..1d0832d9a 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -90,15 +90,18 @@ class Reminders(Cog): delivery_dt: t.Optional[datetime], ) -> None: """Send an embed confirming the reminder change was made successfully.""" - embed = discord.Embed() - embed.colour = discord.Colour.green() - embed.title = random.choice(POSITIVE_REPLIES) - embed.description = on_success + embed = discord.Embed( + description=on_success, + colour=discord.Colour.green(), + title=random.choice(POSITIVE_REPLIES) + ) footer_str = f"ID: {reminder_id}" + if delivery_dt: # Reminder deletion will have a `None` `delivery_dt` - footer_str = f"{footer_str}, Due: {delivery_dt.strftime('%Y-%m-%dT%H:%M:%S')}" + footer_str += ', Done at' + embed.timestamp = delivery_dt embed.set_footer(text=footer_str) -- cgit v1.2.3 From 3188d61f9f6ef871864aed273844ff6a57eb36a0 Mon Sep 17 00:00:00 2001 From: ToxicKidz <78174417+ToxicKidz@users.noreply.github.com> Date: Wed, 21 Apr 2021 10:42:31 -0400 Subject: chore: Revert back to 'Due' --- bot/exts/utils/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 1d0832d9a..6c21920a1 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -100,7 +100,7 @@ class Reminders(Cog): if delivery_dt: # Reminder deletion will have a `None` `delivery_dt` - footer_str += ', Done at' + footer_str += ', Due' embed.timestamp = delivery_dt embed.set_footer(text=footer_str) -- cgit v1.2.3 From 1fdd5aabd4ef5e356f358fdb6e9b26a5b5da99ce Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sat, 24 Apr 2021 17:04:48 +0200 Subject: Tests: simplify public flags handling Co_authored-by: Numerlor <25886452+Numerlor@users.noreply.github.com> --- tests/bot/exts/info/test_information.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index d2ecee033..770660fe3 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -281,13 +281,10 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): """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)) user = helpers.MockMember() - public_flags = unittest.mock.MagicMock() - public_flags.__iter__.return_value = iter(()) - public_flags.verified_bot = False + user.public_flags = unittest.mock.MagicMock(verified_bot=False) user.nick = None user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") user.colour = 0 - user.public_flags = public_flags embed = await self.cog.create_user_embed(ctx, user) @@ -301,13 +298,10 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): """The embed should use the nick if it's available.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) user = helpers.MockMember() - public_flags = unittest.mock.MagicMock() - public_flags.__iter__.return_value = iter(()) - public_flags.verified_bot = False + user.public_flags = unittest.mock.MagicMock(verified_bot=False) user.nick = "Cat lover" user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") user.colour = 0 - user.public_flags = public_flags embed = await self.cog.create_user_embed(ctx, user) -- cgit v1.2.3 From 9affdb92bb67794fd11732376ec64362da932817 Mon Sep 17 00:00:00 2001 From: rohan Date: Sun, 25 Apr 2021 21:05:09 +0530 Subject: Wait for cache to be loaded before accesing member voice state and channels. --- bot/exts/moderation/stream.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index a2ebb6205..ebcc00ace 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -72,6 +72,7 @@ class Stream(commands.Cog): async def _suspend_stream(self, ctx: commands.Context, member: discord.Member) -> None: """Suspend a member's stream.""" + await self.bot.wait_until_guild_available() voice_state = member.voice if not voice_state: -- cgit v1.2.3 From 3fa889aaee4a4d901ce17a24dd6760a4fea88fd7 Mon Sep 17 00:00:00 2001 From: Andi Qu <31325319+dolphingarlic@users.noreply.github.com> Date: Tue, 27 Apr 2021 08:39:51 +0200 Subject: Merge two comments into one Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- bot/exts/info/code_snippets.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index b9e7cc3d0..c20115830 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -231,8 +231,7 @@ class CodeSnippets(Cog): snippet = await handler(**match.groupdict()) all_snippets.append((match.start(), snippet)) - # Sorts the list of snippets by their match index and joins them into - # a single message + # Sorts the list of snippets by their match index and joins them into a single message message_to_send = '\n'.join(map(lambda x: x[1], sorted(all_snippets))) if 0 < len(message_to_send) <= 2000 and len(all_snippets) <= 15: -- cgit v1.2.3 From 42caf7fdfc7c5b7fc7a72039763d78a756bfdd44 Mon Sep 17 00:00:00 2001 From: Andi Qu <31325319+dolphingarlic@users.noreply.github.com> Date: Tue, 27 Apr 2021 12:56:35 +0200 Subject: Fixed the line limit and halved the char limit --- bot/exts/info/code_snippets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index c20115830..3f07e7193 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -234,7 +234,7 @@ class CodeSnippets(Cog): # Sorts the list of snippets by their match index and joins them into a single message message_to_send = '\n'.join(map(lambda x: x[1], sorted(all_snippets))) - if 0 < len(message_to_send) <= 2000 and len(all_snippets) <= 15: + if 0 < len(message_to_send) <= 1000 and message_to_send.count('\n') <= 15: await message.edit(suppress=True) await wait_for_deletion( await message.channel.send(message_to_send), -- cgit v1.2.3 From 9bf55faab91feea9a663d8110f5ab3a4c40ad837 Mon Sep 17 00:00:00 2001 From: Andi Qu <31325319+dolphingarlic@users.noreply.github.com> Date: Tue, 27 Apr 2021 13:21:38 +0200 Subject: Redirect to #bot-commands if the message's length is > 1000 --- bot/exts/info/code_snippets.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 3f07e7193..d93ace31c 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -8,6 +8,7 @@ from discord import Message from discord.ext.commands import Cog from bot.bot import Bot +from bot.constants import Channels from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) @@ -234,12 +235,22 @@ class CodeSnippets(Cog): # Sorts the list of snippets by their match index and joins them into a single message message_to_send = '\n'.join(map(lambda x: x[1], sorted(all_snippets))) - if 0 < len(message_to_send) <= 1000 and message_to_send.count('\n') <= 15: + if 0 < len(message_to_send) <= 2000 and message_to_send.count('\n') <= 15: await message.edit(suppress=True) - await wait_for_deletion( - await message.channel.send(message_to_send), - (message.author.id,) - ) + if len(message_to_send) > 1000 and message.channel.id != Channels.bot_commands: + # Redirects to #bot-commands if the snippet contents are too long + await message.channel.send(('The snippet you tried to send was too long. Please ' + f'see <#{Channels.bot_commands}> for the full snippet.')) + bot_commands_channel = self.bot.get_channel(Channels.bot_commands) + await wait_for_deletion( + await bot_commands_channel.send(message_to_send), + (message.author.id,) + ) + else: + await wait_for_deletion( + await message.channel.send(message_to_send), + (message.author.id,) + ) def setup(bot: Bot) -> None: -- cgit v1.2.3 From 99549d7e76556c09d27148ee43fa61a38bc9a0b4 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 27 Apr 2021 16:58:41 +0200 Subject: Use a specific error message when a warned user isn't in the guild This commit changes sighly how the warn, kick and mute commands to take a fetched member as their argument and to return a little error message if the user isn't in the guild rather than showing the whole help page. --- bot/exts/moderation/infraction/infractions.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index d89e80acc..38d1ffc0e 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -54,8 +54,12 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Permanent infractions @command() - async def warn(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: + async def warn(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: """Warn a user for the given reason.""" + if not isinstance(user, Member): + await ctx.send(":x: The user doesn't appear to be on the server.") + return + infraction = await _utils.post_infraction(ctx, user, "warning", reason, active=False) if infraction is None: return @@ -63,8 +67,12 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user) @command() - async def kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: + async def kick(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: """Kick a user for the given reason.""" + if not isinstance(user, Member): + await ctx.send(":x: The user doesn't appear to be on the server.") + return + await self.apply_kick(ctx, user, reason) @command() @@ -100,7 +108,7 @@ class Infractions(InfractionScheduler, commands.Cog): @command(aliases=["mute"]) async def tempmute( self, ctx: Context, - user: Member, + user: FetchedMember, duration: t.Optional[Expiry] = None, *, reason: t.Optional[str] = None @@ -122,6 +130,10 @@ class Infractions(InfractionScheduler, commands.Cog): If no duration is given, a one hour duration is used by default. """ + if not isinstance(user, Member): + await ctx.send(":x: The user doesn't appear to be on the server.") + return + if duration is None: duration = await Duration().convert(ctx, "1h") await self.apply_mute(ctx, user, reason, expires_at=duration) -- cgit v1.2.3 From c01caf42401b6d029014f90a52966ee55a649194 Mon Sep 17 00:00:00 2001 From: Andi Qu <31325319+dolphingarlic@users.noreply.github.com> Date: Tue, 27 Apr 2021 18:28:49 +0200 Subject: Wait for cache to fill before redirecting --- bot/exts/info/code_snippets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index d93ace31c..6ebc5e74b 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -239,6 +239,7 @@ class CodeSnippets(Cog): await message.edit(suppress=True) if len(message_to_send) > 1000 and message.channel.id != Channels.bot_commands: # Redirects to #bot-commands if the snippet contents are too long + await self.bot.wait_until_guild_available() await message.channel.send(('The snippet you tried to send was too long. Please ' f'see <#{Channels.bot_commands}> for the full snippet.')) bot_commands_channel = self.bot.get_channel(Channels.bot_commands) -- cgit v1.2.3 From 2edba253c93a9272f9a6a579981c7dfb9358f80c Mon Sep 17 00:00:00 2001 From: rohan Date: Wed, 28 Apr 2021 11:30:39 +0530 Subject: Use guild.afk_channel atr to retrieve afk Channel instance. --- bot/constants.py | 1 - bot/exts/moderation/stream.py | 2 +- config-default.yml | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index b9444c989..6d14bbb3a 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -444,7 +444,6 @@ class Channels(metaclass=YAMLGetter): mod_announcements: int staff_announcements: int - afk_voice: int admins_voice: int code_help_voice_1: int code_help_voice_2: int diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index ebcc00ace..1710d4c7c 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -82,7 +82,7 @@ class Stream(commands.Cog): if voice_state.self_stream: # End user's stream by moving them to AFK voice channel and back. original_vc = voice_state.channel - await member.move_to(self.bot.get_channel(Channels.afk_voice)) + await member.move_to(ctx.guild.afk_channel) await member.move_to(original_vc) # Notify. diff --git a/config-default.yml b/config-default.yml index 204397f7f..8c6e18470 100644 --- a/config-default.yml +++ b/config-default.yml @@ -206,7 +206,6 @@ guild: staff_announcements: &STAFF_ANNOUNCEMENTS 464033278631084042 # Voice Channels - afk_voice: 756327105389920306 admins_voice: &ADMINS_VOICE 500734494840717332 code_help_voice_1: 751592231726481530 code_help_voice_2: 764232549840846858 -- cgit v1.2.3 From 32b783f0b207450b46510a810a36999189b97985 Mon Sep 17 00:00:00 2001 From: rohan Date: Wed, 28 Apr 2021 12:37:12 +0530 Subject: Make flake8 happy :D --- bot/exts/moderation/stream.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index 1710d4c7c..fd856a7f4 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -9,7 +9,7 @@ from async_rediscache import RedisCache from discord.ext import commands from bot.bot import Bot -from bot.constants import Channels, Colours, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES, VideoPermission +from bot.constants import Colours, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES, VideoPermission from bot.converters import Expiry from bot.pagination import LinePaginator from bot.utils.scheduling import Scheduler -- cgit v1.2.3 From 9ffe5b0146354936c7c67e43bd6682195ccedfdd Mon Sep 17 00:00:00 2001 From: kosayoda Date: Wed, 28 Apr 2021 16:43:52 +0800 Subject: Remove BrandingError check. This was removed in the branding manager rewrite: https://github.com/python-discord/bot/pull/1463/ --- tests/bot/exts/backend/test_error_handler.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py index 9b7b66cb2..1b4729cbc 100644 --- a/tests/bot/exts/backend/test_error_handler.py +++ b/tests/bot/exts/backend/test_error_handler.py @@ -5,7 +5,6 @@ from discord.ext.commands import errors from bot.api import ResponseCodeError from bot.errors import InvalidInfractedUser, LockedResourceError -from bot.exts.backend.branding._errors import BrandingError from bot.exts.backend.error_handler import ErrorHandler, setup from bot.exts.info.tags import Tags from bot.exts.moderation.silence import Silence @@ -130,10 +129,6 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): "args": (self.ctx, errors.CommandInvokeError(LockedResourceError("abc", "test"))), "expect_mock_call": "send" }, - { - "args": (self.ctx, errors.CommandInvokeError(BrandingError())), - "expect_mock_call": "send" - }, { "args": (self.ctx, errors.CommandInvokeError(InvalidInfractedUser(self.ctx.author))), "expect_mock_call": "send" -- cgit v1.2.3 From e6bb1b321d9b657309c4c4c6f445f33a0e9e563e Mon Sep 17 00:00:00 2001 From: kosayoda Date: Wed, 28 Apr 2021 16:45:18 +0800 Subject: Address error behavior update. BadUnionArgument sends command help after: https://github.com/python-discord/bot/pull/1434 --- bot/exts/backend/error_handler.py | 4 ++-- tests/bot/exts/backend/test_error_handler.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index f3bb3426a..d8de177f5 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -230,12 +230,12 @@ class ErrorHandler(Cog): elif isinstance(e, errors.BadUnionArgument): embed = self._get_error_embed("Bad argument", f"{e}\n{e.errors[-1]}") await ctx.send(embed=embed) - await prepared_help_command + await self.get_help_command(ctx) self.bot.stats.incr("errors.bad_union_argument") elif isinstance(e, errors.ArgumentParsingError): embed = self._get_error_embed("Argument parsing error", str(e)) await ctx.send(embed=embed) - prepared_help_command.close() + self.get_help_command(ctx).close() self.bot.stats.incr("errors.argument_parsing_error") else: embed = self._get_error_embed( diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py index 1b4729cbc..bd4fb5942 100644 --- a/tests/bot/exts/backend/test_error_handler.py +++ b/tests/bot/exts/backend/test_error_handler.py @@ -379,7 +379,7 @@ class IndividualErrorHandlerTests(unittest.IsolatedAsyncioTestCase): }, { "error": errors.BadUnionArgument(MagicMock(), MagicMock(), MagicMock()), - "call_prepared": False + "call_prepared": True }, { "error": errors.ArgumentParsingError(), -- cgit v1.2.3 From 197279d7045536d520db91f49db8d04c23fea090 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 28 Apr 2021 22:20:22 -0700 Subject: CodeSnippets: avoid returning None when request raises an exception Move the exception handling to `on_message` to avoid writing a lot of None checks; `_fetch_response` is used multiple times in various places. Fixes #1554 Fixes BOT-Z7 --- bot/exts/info/code_snippets.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index c20115830..b552efcea 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -45,14 +45,11 @@ class CodeSnippets(Cog): async def _fetch_response(self, url: str, response_format: str, **kwargs) -> str: """Makes http requests using aiohttp.""" - try: - async with self.bot.http_session.get(url, raise_for_status=True, **kwargs) as response: - if response_format == 'text': - return await response.text() - elif response_format == 'json': - return await response.json() - except ClientResponseError as error: - log.error(f'Failed to fetch code snippet from {url}. HTTP Status: {error.status}. Message: {str(error)}.') + async with self.bot.http_session.get(url, raise_for_status=True, **kwargs) as response: + if response_format == 'text': + return await response.text() + elif response_format == 'json': + return await response.json() def _find_ref(self, path: str, refs: tuple) -> tuple: """Loops through all branches and tags to find the required ref.""" @@ -228,8 +225,14 @@ class CodeSnippets(Cog): for pattern, handler in self.pattern_handlers: for match in pattern.finditer(message.content): - snippet = await handler(**match.groupdict()) - all_snippets.append((match.start(), snippet)) + try: + snippet = await handler(**match.groupdict()) + all_snippets.append((match.start(), snippet)) + except ClientResponseError as error: + log.error( + f'Failed to fetch code snippet from {error.request_info.real_url}. ' + f'Status: {error.status}. Message: {error}.' + ) # Sorts the list of snippets by their match index and joins them into a single message message_to_send = '\n'.join(map(lambda x: x[1], sorted(all_snippets))) -- cgit v1.2.3 From 742618f71763184ad679f86f9a2bfb6419ec8646 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 28 Apr 2021 22:20:39 -0700 Subject: CodeSnippets: use a lower log level for 404 responses Just cause a URL looks valid doesn't mean it will be valid, so a 404 is a normal and harmless error. Fixes #1553 Fixes BOT-Z4 Fixes BOT-Z8 Fixes BOT-Z9 --- bot/exts/info/code_snippets.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index b552efcea..5c6cb5ae2 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -229,7 +229,8 @@ class CodeSnippets(Cog): snippet = await handler(**match.groupdict()) all_snippets.append((match.start(), snippet)) except ClientResponseError as error: - log.error( + log.log( + logging.DEBUG if error.status == 404 else logging.ERROR, f'Failed to fetch code snippet from {error.request_info.real_url}. ' f'Status: {error.status}. Message: {error}.' ) -- cgit v1.2.3 From 1d5a01b2c9e49ed3a24f8630374dc63a703a2187 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 28 Apr 2021 22:36:25 -0700 Subject: CodeSnippets: add more detail to the request error message --- bot/exts/info/code_snippets.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 5c6cb5ae2..3c8b862c3 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -229,10 +229,11 @@ class CodeSnippets(Cog): snippet = await handler(**match.groupdict()) all_snippets.append((match.start(), snippet)) except ClientResponseError as error: + error_message = error.message # noqa: B306 log.log( logging.DEBUG if error.status == 404 else logging.ERROR, - f'Failed to fetch code snippet from {error.request_info.real_url}. ' - f'Status: {error.status}. Message: {error}.' + f'Failed to fetch code snippet from {match[0]!r}: {error.status} ' + f'{error_message} for GET {error.request_info.real_url.human_repr()}' ) # Sorts the list of snippets by their match index and joins them into a single message -- cgit v1.2.3 From 41b5c2409fff548545e463759f22500e9244b375 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 28 Apr 2021 22:43:51 -0700 Subject: CodeSnippets: fix type annotations --- bot/exts/info/code_snippets.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 3c8b862c3..3b2a8a0a5 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -1,6 +1,7 @@ import logging import re import textwrap +from typing import Any from urllib.parse import quote_plus from aiohttp import ClientResponseError @@ -43,7 +44,7 @@ class CodeSnippets(Cog): Matches each message against a regex and prints the contents of all matched snippets. """ - async def _fetch_response(self, url: str, response_format: str, **kwargs) -> str: + async def _fetch_response(self, url: str, response_format: str, **kwargs) -> Any: """Makes http requests using aiohttp.""" async with self.bot.http_session.get(url, raise_for_status=True, **kwargs) as response: if response_format == 'text': @@ -61,7 +62,7 @@ class CodeSnippets(Cog): ref = possible_ref['name'] file_path = path[len(ref) + 1:] break - return (ref, file_path) + return ref, file_path async def _fetch_github_snippet( self, @@ -145,8 +146,8 @@ class CodeSnippets(Cog): repo: str, ref: str, file_path: str, - start_line: int, - end_line: int + start_line: str, + end_line: str ) -> str: """Fetches a snippet from a BitBucket repo.""" file_contents = await self._fetch_response( -- cgit v1.2.3 From bb09cb5e520d7662ca7b19e0aad334160a532a9e Mon Sep 17 00:00:00 2001 From: ToxicKidz <78174417+ToxicKidz@users.noreply.github.com> Date: Thu, 29 Apr 2021 13:50:31 -0400 Subject: feat: Use embed timestamp in modpings off --- bot/exts/moderation/modpings.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 2f180e594..274952d8a 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -3,11 +3,11 @@ import logging from async_rediscache import RedisCache from dateutil.parser import isoparse -from discord import Member +from discord import Embed, Member from discord.ext.commands import Cog, Context, group, has_any_role from bot.bot import Bot -from bot.constants import Emojis, Guild, MODERATION_ROLES, Roles +from bot.constants import Colours, Emojis, Guild, Icons, MODERATION_ROLES, Roles from bot.converters import Expiry from bot.utils.scheduling import Scheduler @@ -104,7 +104,9 @@ class ModPings(Cog): self._role_scheduler.cancel(mod.id) self._role_scheduler.schedule_at(duration, mod.id, self.reapply_role(mod)) - await ctx.send(f"{Emojis.check_mark} Moderators role has been removed until {until_date}.") + embed = Embed(timestamp=duration, colour=Colours.bright_green) + embed.set_footer(text="Moderators role has been removed", icon_url=Icons.green_checkmark) + await ctx.send(embed=embed) @modpings_group.command(name='on') @has_any_role(*MODERATION_ROLES) -- cgit v1.2.3 From 5c1cf2b5fd94359d42d147ce8687dd27b05e193d Mon Sep 17 00:00:00 2001 From: ToxicKidz <78174417+ToxicKidz@users.noreply.github.com> Date: Fri, 30 Apr 2021 22:02:07 -0400 Subject: chore: Add the missing 'until' --- bot/exts/moderation/modpings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 274952d8a..1ad5005de 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -105,7 +105,7 @@ class ModPings(Cog): self._role_scheduler.schedule_at(duration, mod.id, self.reapply_role(mod)) embed = Embed(timestamp=duration, colour=Colours.bright_green) - embed.set_footer(text="Moderators role has been removed", icon_url=Icons.green_checkmark) + embed.set_footer(text="Moderators role has been removed until", icon_url=Icons.green_checkmark) await ctx.send(embed=embed) @modpings_group.command(name='on') -- cgit v1.2.3 From 52be2e92cfe62a0ed35811ec73e22e3e63275e88 Mon Sep 17 00:00:00 2001 From: Xithrius <15021300+Xithrius@users.noreply.github.com> Date: Mon, 3 May 2021 16:14:30 -0700 Subject: Removed opinions from text. Co-authored-by: Boris Muratov <8bee278@gmail.com> --- bot/resources/tags/str-join.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/resources/tags/str-join.md b/bot/resources/tags/str-join.md index a6b8fb793..c835f9313 100644 --- a/bot/resources/tags/str-join.md +++ b/bot/resources/tags/str-join.md @@ -1,6 +1,6 @@ **Joining Iterables** -Suppose you want to nicely display a list (or some other iterable). The naive solution would be something like this. +If you want to display a list (or some other iterable), you can write: ```py colors = ['red', 'green', 'blue', 'yellow'] output = "" @@ -10,16 +10,16 @@ for color in colors: print(output) # Prints 'red, green, blue, yellow, ' ``` -However, this solution is flawed. The separator is still added to the last element, and it is slow. +However, the separator is still added to the last element, and it is relatively slow. -The better solution is to use `str.join`. +A better solution is to use `str.join`. ```py colors = ['red', 'green', 'blue', 'yellow'] separator = ", " print(separator.join(colors)) # Prints 'red, green, blue, yellow' ``` -This solution is much simpler, faster, and solves the problem of the extra separator. An important thing to note is that you can only `str.join` strings. For a list of ints, +An important thing to note is that you can only `str.join` strings. For a list of ints, you must convert each element to a string before joining. ```py integers = [1, 3, 6, 10, 15] -- cgit v1.2.3 From 70bfc1086c6e6213801c315fe3afbe89feedc793 Mon Sep 17 00:00:00 2001 From: vcokltfre Date: Wed, 5 May 2021 09:54:06 +0100 Subject: fix: remove the newline --- bot/resources/tags/identity.md | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/resources/tags/identity.md b/bot/resources/tags/identity.md index 32995aef6..033fc0cec 100644 --- a/bot/resources/tags/identity.md +++ b/bot/resources/tags/identity.md @@ -11,7 +11,6 @@ if x == 3: print("x equals 3") # Prints 'x equals 5' ``` - To check if two things are actually the same thing in memory, use the identity comparison operator (`is`). ```py x = [1, 2, 3] -- cgit v1.2.3 From e13018dc92fb2b9fa98696cbca4697464bf9ff67 Mon Sep 17 00:00:00 2001 From: vcokltfre Date: Wed, 5 May 2021 10:10:16 +0100 Subject: fix: make requested changes --- bot/resources/tags/identity.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/bot/resources/tags/identity.md b/bot/resources/tags/identity.md index 033fc0cec..fb2010759 100644 --- a/bot/resources/tags/identity.md +++ b/bot/resources/tags/identity.md @@ -2,7 +2,7 @@ Should I be using `is` or `==`? -To check if two things are equal, use the equality operator (`==`). +To check if two objects are equal, use the equality operator (`==`). ```py x = 5 if x == 5: @@ -11,14 +11,14 @@ if x == 3: print("x equals 3") # Prints 'x equals 5' ``` -To check if two things are actually the same thing in memory, use the identity comparison operator (`is`). +To check if two objects are actually the same thing in memory, use the identity comparison operator (`is`). ```py -x = [1, 2, 3] -y = [1, 2, 3] -if x is [1, 2, 3]: - print("x is y") -z = x -if x is z: - print("x is z") -# Prints 'x is z' +list_1 = [1, 2, 3] +list_2 = [1, 2, 3] +if list_1 is [1, 2, 3]: + print("list_1 is list_2") +reference_to_list_1 = list_1 +if list_1 is reference_to_list_1: + print("list_1 is reference_to_list_1") +# Prints 'list_1 is reference_to_list_1' ``` -- cgit v1.2.3